/** * WebGL Book Scene Module * Creates the canvas-first UI shell and a page-turn-ready book scene. */ import { BaseModule } from './base-module.js'; class WebGLBookSceneModule extends BaseModule { constructor() { super('webgl-book-scene', 'WebGL Book Scene'); this.dependencies = []; this.THREE = null; this.GLTFLoader = null; this.renderer = null; this.scene = null; this.camera = null; this.clock = null; this.mixer = null; this.openingAction = null; this.bookGroup = null; this.bookModel = null; this.pageTextureApplied = false; this.bookModelPath = '/assets/webgl/book/old_magical_book_metalrough.glb'; this.leftPageTexture = null; this.rightPageTexture = null; this.leftTextureCanvas = null; this.rightTextureCanvas = null; this.lastTextureUpdate = 0; this.tableTopY = -0.09; this.openHoldTime = 4; this.openAnimationDone = false; this.bindMethods([ 'ensureShell', 'initializeScene', 'loadBookModel', 'placeBookForOpenPose', 'applyDynamicPageTextures', 'remapRightPageUv', 'createPageTexture', 'drawPageTexture', 'adoptPageContent', 'refreshModalOverview', 'updateSceneSize', 'animate', 'triggerPageTurn' ]); } async initialize() { try { this.reportProgress(10, 'Creating WebGL shell'); this.ensureShell(); this.reportProgress(30, 'Loading Three.js'); this.THREE = await import('https://esm.sh/three@0.165.0'); const gltfModule = await import('https://esm.sh/three@0.165.0/examples/jsm/loaders/GLTFLoader.js'); this.GLTFLoader = gltfModule.GLTFLoader; this.reportProgress(55, 'Building book scene'); await this.initializeScene(); this.addEventListener(window, 'resize', this.updateSceneSize); this.addEventListener(document, 'story:turn-start', this.triggerPageTurn); this.addEventListener(document, 'story:turn-complete', this.triggerPageTurn); this.addEventListener(document, 'game:config', () => this.refreshModalOverview()); this.reportProgress(100, 'WebGL book scene ready'); return true; } catch (error) { console.warn('WebGLBookScene: Falling back to DOM-only shell:', error); this.ensureShell(); this.reportProgress(100, 'WebGL fallback shell ready'); return true; } } ensureShell() { document.body.classList.add('webgl-mode'); let app = document.getElementById('webgl_app'); if (!app) { app = document.createElement('div'); app.id = 'webgl_app'; document.body.prepend(app); } let canvas = document.getElementById('webgl_canvas'); if (!canvas) { canvas = document.createElement('canvas'); canvas.id = 'webgl_canvas'; canvas.setAttribute('aria-label', '3D book scene'); app.appendChild(canvas); } let topMenu = document.getElementById('top_menu'); if (!topMenu) { topMenu = document.createElement('nav'); topMenu.id = 'top_menu'; topMenu.setAttribute('aria-label', 'Top menu'); topMenu.innerHTML = `
AI Interactive Fiction
`; app.appendChild(topMenu); topMenu.addEventListener('click', (event) => { const button = event.target?.closest?.('[data-modal-target]'); if (!button) return; const targetId = button.dataset.modalTarget; const existing = document.getElementById(targetId); if (targetId === 'options-modal') { document.getElementById('options')?.click(); window.setTimeout(() => this.refreshModalOverview(), 100); return; } if (targetId === 'credits_modal') { document.getElementById('credits_button')?.click(); } if (existing) { existing.classList.add('visible'); existing.style.display = 'block'; existing.setAttribute('aria-hidden', 'false'); } this.refreshModalOverview(); }); } let modalOverview = document.getElementById('modal_overview'); if (!modalOverview) { modalOverview = document.createElement('aside'); modalOverview.id = 'modal_overview'; modalOverview.setAttribute('aria-label', 'Modal overview'); modalOverview.innerHTML = ''; app.appendChild(modalOverview); } let book = document.getElementById('book'); if (!book) { book = document.createElement('div'); book.id = 'book'; app.appendChild(book); } else if (book.parentElement !== app) { app.appendChild(book); } if (!book.dataset.webglOverlayBound) { book.dataset.webglOverlayBound = 'true'; book.addEventListener('input', () => this.drawPageTexture(), true); book.addEventListener('change', () => this.drawPageTexture(), true); } this.refreshModalOverview(); } async initializeScene() { const THREE = this.THREE; const canvas = document.getElementById('webgl_canvas'); if (!THREE || !canvas) return; this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); this.renderer.shadowMap.enabled = true; this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x0b0907); this.clock = new THREE.Clock(); this.camera = new THREE.PerspectiveCamera(30, 1, 0.1, 100); this.camera.position.set(0, 3.1, 4.25); this.camera.lookAt(0, 0.12, 0); const keyLight = new THREE.DirectionalLight(0xffead2, 2.15); keyLight.position.set(-2.8, 5.1, 3.4); keyLight.castShadow = true; keyLight.shadow.bias = -0.00015; keyLight.shadow.normalBias = 0.025; keyLight.shadow.mapSize.set(2048, 2048); this.scene.add(keyLight); this.scene.add(new THREE.AmbientLight(0xa98963, 1.85)); const textureLoader = new THREE.TextureLoader(); const tableTexture = await textureLoader.loadAsync('/assets/webgl/wood_table_diff_1k.jpg').catch(() => null); if (tableTexture) { tableTexture.colorSpace = THREE.SRGBColorSpace; tableTexture.wrapS = THREE.RepeatWrapping; tableTexture.wrapT = THREE.RepeatWrapping; tableTexture.repeat.set(3, 2); } const tableMaterial = tableTexture ? new THREE.MeshStandardMaterial({ map: tableTexture, roughness: 0.62, metalness: 0 }) : new THREE.MeshStandardMaterial({ color: 0x5a2f19, roughness: 0.76 }); const table = new THREE.Mesh(new THREE.BoxGeometry(10.5, 0.34, 7.2), tableMaterial); table.position.y = -0.26; table.receiveShadow = true; this.scene.add(table); await this.loadBookModel(); this.updateSceneSize(); this.animate(); } async loadBookModel() { const THREE = this.THREE; if (!THREE || !this.scene || !this.GLTFLoader) return; this.bookGroup = new THREE.Group(); this.bookGroup.position.set(0, 0, 0); this.bookGroup.rotation.set(0, 0, 0); this.scene.add(this.bookGroup); const loader = new this.GLTFLoader(); const gltf = await loader.loadAsync(this.bookModelPath); const model = gltf.scene; this.bookModel = model; model.traverse((object) => { if (!object.isMesh) return; object.castShadow = false; object.receiveShadow = true; if (object.material?.map) { object.material.map.colorSpace = THREE.SRGBColorSpace; } }); const bounds = new THREE.Box3().setFromObject(model); const size = bounds.getSize(new THREE.Vector3()); const center = bounds.getCenter(new THREE.Vector3()); model.position.sub(center); const tableFootprint = Math.max(size.x, size.z, 1); const scale = 3.35 / tableFootprint; model.scale.setScalar(scale); model.rotation.y = 0; this.bookGroup.add(model); if (gltf.animations?.length) { this.mixer = new THREE.AnimationMixer(model); const action = this.mixer.clipAction(gltf.animations[0]); this.openingAction = action; action.reset(); action.setLoop(THREE.LoopOnce, 1); action.clampWhenFinished = true; action.timeScale = 1; action.play(); this.placeBookForOpenPose(action); action.reset(); action.setLoop(THREE.LoopOnce, 1); action.clampWhenFinished = true; action.timeScale = 1; action.play(); } else { model.updateMatrixWorld(true); const placedBounds = new THREE.Box3().setFromObject(model); model.position.y += this.tableTopY - placedBounds.min.y + 0.012; } this.applyDynamicPageTextures(model); } placeBookForOpenPose(action) { const THREE = this.THREE; if (!THREE || !this.bookModel || !this.mixer || !action) return; this.mixer.setTime(this.openHoldTime); this.bookModel.updateMatrixWorld(true); const bounds = new THREE.Box3().setFromObject(this.bookModel); const center = bounds.getCenter(new THREE.Vector3()); this.bookModel.position.x -= center.x; this.bookModel.position.y += this.tableTopY - bounds.min.y + 0.012; this.bookModel.position.z -= center.z; this.bookModel.updateMatrixWorld(true); } applyDynamicPageTextures(model) { const THREE = this.THREE; if (!THREE || !model) return; this.leftTextureCanvas = this.createPageTexture(); this.rightTextureCanvas = this.createPageTexture(); this.leftPageTexture = new THREE.CanvasTexture(this.leftTextureCanvas); this.rightPageTexture = new THREE.CanvasTexture(this.rightTextureCanvas); this.leftPageTexture.colorSpace = THREE.SRGBColorSpace; this.rightPageTexture.colorSpace = THREE.SRGBColorSpace; model.traverse((object) => { if (object.isMesh) { delete object.userData.dynamicPageTexture; } }); const namedTargets = [ { name: 'page1_005Shape', side: 'left' }, { name: 'page1_004Shape', side: 'left' }, { name: 'page1_002Shape', side: 'left' }, { name: 'page1_001Shape', side: 'left' }, { name: 'page1Shape', side: 'left' }, { name: 'CubeShape', side: 'right' } ]; let appliedNamedTargets = 0; namedTargets.forEach((target) => { const object = model.getObjectByName(target.name); if (!object?.isMesh) return; const texture = target.side === 'left' ? this.leftPageTexture : this.rightPageTexture; if (target.name === 'CubeShape') { this.remapRightPageUv(object); } const material = new THREE.MeshStandardMaterial({ map: texture, color: 0xffffff, roughness: 0.96, metalness: 0, side: THREE.DoubleSide }); object.material = material; object.userData.dynamicPageTexture = target.side; appliedNamedTargets += 1; }); this.pageTextureApplied = appliedNamedTargets > 0; this.drawPageTexture(); } remapRightPageUv(object) { const THREE = this.THREE; const geometry = object?.geometry; const position = geometry?.attributes?.position; const uv = geometry?.attributes?.uv; if (!THREE || !object?.isMesh || !position || !uv) return; object.updateMatrixWorld(true); const morphPositions = geometry.morphAttributes?.position || []; const influences = object.morphTargetInfluences || []; const selected = [30, 31, 32, 33, 34, 35]; const local = new THREE.Vector3(); const world = new THREE.Vector3(); const points = new Map(); selected.forEach((index) => { local.fromBufferAttribute(position, index); morphPositions.forEach((morph, morphIndex) => { const influence = influences[morphIndex] || 0; if (!influence) return; local.x += morph.getX(index) * influence; local.y += morph.getY(index) * influence; local.z += morph.getZ(index) * influence; }); points.set(index, world.copy(local).applyMatrix4(object.matrixWorld).clone()); }); const bounds = Array.from(points.values()).reduce((acc, point) => ({ minX: Math.min(acc.minX, point.x), maxX: Math.max(acc.maxX, point.x), minZ: Math.min(acc.minZ, point.z), maxZ: Math.max(acc.maxZ, point.z) }), { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity }); const width = Math.max(0.001, bounds.maxX - bounds.minX); const depth = Math.max(0.001, bounds.maxZ - bounds.minZ); points.forEach((point, index) => { const u = (point.x - bounds.minX) / width; const v = 1 - ((point.z - bounds.minZ) / depth); uv.setXY(index, Math.min(1, Math.max(0, u)), Math.min(1, Math.max(0, v))); }); uv.needsUpdate = true; } createPageTexture() { const canvas = document.createElement('canvas'); canvas.width = 1024; canvas.height = 1304; return canvas; } drawPageTexture() { this.paintDomPage(this.leftTextureCanvas, document.getElementById('page_left'), 'left'); this.paintDomPage(this.rightTextureCanvas, document.getElementById('page_right'), 'right'); if (this.leftPageTexture) this.leftPageTexture.needsUpdate = true; if (this.rightPageTexture) this.rightPageTexture.needsUpdate = true; } paintDomPage(canvas, source, side) { if (!canvas) return; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f4dfad'; ctx.fillRect(0, 0, canvas.width, canvas.height); const gradient = ctx.createLinearGradient(0, 0, canvas.width, 0); gradient.addColorStop(0, 'rgba(83, 49, 23, 0.16)'); gradient.addColorStop(0.15, 'rgba(255, 255, 255, 0)'); gradient.addColorStop(0.88, 'rgba(255, 255, 255, 0)'); gradient.addColorStop(1, 'rgba(83, 49, 23, 0.13)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(120, 84, 44, 0.12)'; for (let y = 64; y < canvas.height; y += 52) { ctx.fillRect(96, y, canvas.width - 192, 2); } ctx.fillStyle = '#160d08'; ctx.font = '48px "EB Garamond", Georgia, serif'; ctx.textBaseline = 'top'; const blocks = this.collectPageBlocks(source, side); if (blocks.length === 0) { blocks.push({ text: side === 'left' ? 'Menu and commands' : 'Story text', role: 'body', align: 'left' }); } ctx.fillStyle = '#120b07'; let y = side === 'left' ? 150 : 120; if (side === 'right' && blocks.length <= 2) { y = 360; } blocks.forEach((block) => { const fontSize = this.getTextureFontSize(block.role); ctx.font = `${block.italic ? 'italic ' : ''}${fontSize}px "EB Garamond", Georgia, serif`; ctx.textAlign = block.align || 'left'; const x = ctx.textAlign === 'center' ? canvas.width / 2 : 118; const maxWidth = ctx.textAlign === 'center' ? canvas.width - 180 : canvas.width - 236; if (block.role === 'separator') { y += 18; } y = this.wrapCanvasText(ctx, block.text, x, y, maxWidth, fontSize * 1.28) + this.getTextureBlockGap(block.role); }); } collectPageBlocks(source, side) { if (!source) return []; if (side === 'left') { const controlLabels = Array.from(source.querySelectorAll('#controls a, #controls span')) .map((element) => element.textContent?.trim() || element.getAttribute('aria-label') || element.id || '') .filter(Boolean) .join(' | '); return [ { text: source.querySelector('#game_author')?.textContent?.trim(), role: 'byline', align: 'center' }, { text: source.querySelector('#game_title')?.textContent?.trim(), role: 'title', align: 'center' }, { text: source.querySelector('#game_subtitle')?.textContent?.trim(), role: 'subtitle', align: 'center' }, { text: source.querySelector('.separator')?.textContent?.trim(), role: 'separator', align: 'center' }, { text: controlLabels, role: 'controls', align: 'center' }, ...Array.from(source.querySelectorAll('#command_history > *, #choices .choice-button')).map((element) => ({ text: element.textContent?.trim(), role: 'choice', align: 'left' })), { text: source.querySelector('#player_input')?.value || source.querySelector('#player_input')?.textContent?.trim(), role: 'input', align: 'left' }, { text: source.querySelector('#remark_text')?.textContent?.trim(), role: 'remark', align: 'center', italic: true }, { text: source.querySelector('#game_legal')?.textContent?.trim(), role: 'legal', align: 'center' } ].filter((block) => block.text); } const storyBlocks = source.querySelector('#paragraphs')?.children?.length ? Array.from(source.querySelector('#paragraphs').children) : Array.from(source.querySelectorAll('#story > *, .history-item, .story-block, p')); const blocks = storyBlocks .map((element) => ({ text: element.textContent?.replace(/\s+/g, ' ').trim(), role: element.matches?.('h1,h2,h3') ? 'subtitle' : 'story', align: 'left' })) .filter((block) => block.text); if (blocks.length) return blocks; const sourceText = source.textContent?.replace(/\s+/g, ' ').trim(); return sourceText ? [{ text: sourceText, role: 'story-focus', align: 'center' }] : []; } getTextureFontSize(role) { const sizes = { byline: 42, title: 76, subtitle: 46, separator: 42, controls: 30, choice: 34, input: 34, remark: 30, legal: 26, 'story-focus': 68, story: 38, body: 38 }; return sizes[role] || sizes.body; } getTextureBlockGap(role) { const gaps = { title: 28, subtitle: 32, separator: 22, controls: 38, choice: 18, 'story-focus': 28, story: 24, legal: 10 }; return gaps[role] || 16; } wrapCanvasText(ctx, text, x, y, maxWidth, lineHeight) { const words = String(text || '').split(/\s+/); let line = ''; words.forEach((word) => { const testLine = line ? `${line} ${word}` : word; if (ctx.measureText(testLine).width > maxWidth && line) { ctx.fillText(line, x, y); line = word; y += lineHeight; } else { line = testLine; } }); if (line) ctx.fillText(line, x, y); return y + lineHeight; } adoptPageContent() { const title = document.getElementById('game_title')?.textContent?.trim(); const topTitle = document.getElementById('top_menu_title'); if (title && topTitle) topTitle.textContent = title; this.refreshModalOverview(); this.drawPageTexture(); } refreshModalOverview() { const list = document.getElementById('modal_overview_list'); if (!list) return; const modals = [ { id: 'options-modal', label: 'Options' }, { id: 'credits_modal', label: 'Credits' }, { id: 'story_popup_modal', label: 'Notice' } ]; list.innerHTML = ''; modals.forEach((modal) => { const element = document.getElementById(modal.id); const computedDisplay = element ? window.getComputedStyle(element).display : 'none'; const isOpen = Boolean(element && ( element.classList.contains('visible') || element.style.display === 'block' || element.style.display === 'flex' || computedDisplay !== 'none' )); const row = document.createElement('button'); row.type = 'button'; row.className = 'modal-overview-row'; row.dataset.modalTarget = modal.id; row.innerHTML = `${modal.label}${isOpen ? 'open' : 'closed'}`; row.addEventListener('click', () => { document.querySelector(`#top_menu [data-modal-target="${modal.id}"]`)?.click(); }); list.appendChild(row); }); } updateSceneSize() { if (!this.renderer || !this.camera) return; const width = Math.max(1, window.innerWidth); const height = Math.max(1, window.innerHeight); this.renderer.setSize(width, height, false); this.camera.aspect = width / height; this.camera.updateProjectionMatrix(); } triggerPageTurn() { this.drawPageTexture(); } animate(now = performance.now()) { if (!this.renderer || !this.scene || !this.camera) return; requestAnimationFrame(this.animate); if (now - this.lastTextureUpdate > 700) { this.lastTextureUpdate = now; this.drawPageTexture(); this.refreshModalOverview(); } if (this.mixer && this.clock) { this.mixer.update(this.clock.getDelta()); if (this.openingAction && this.openingAction.time >= this.openHoldTime && !this.openAnimationDone) { this.openAnimationDone = true; this.openingAction.paused = true; this.openingAction.timeScale = 0; this.openingAction.enabled = true; this.openingAction.setEffectiveWeight(1); } } else { if (!this.pageTextureApplied) { this.applyDynamicPageTextures(this.bookModel); } } this.renderer.render(this.scene, this.camera); } } const WebGLBookScene = new WebGLBookSceneModule(); export { WebGLBookScene }; if (window.moduleRegistry) { window.moduleRegistry.register(WebGLBookScene); } window.WebGLBookScene = WebGLBookScene;