Add WebGL book scene checkpoint

This commit is contained in:
2026-06-04 11:10:48 +02:00
parent bccefd2a68
commit 199462442c
18 changed files with 4685 additions and 4 deletions
+48
View File
@@ -32,6 +32,7 @@
"eslint": "^9.23.0", "eslint": "^9.23.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"playwright": "^1.60.0",
"ts-jest": "^29.3.1", "ts-jest": "^29.3.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.2" "typescript": "^5.8.2"
@@ -6266,6 +6267,53 @@
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+1
View File
@@ -61,6 +61,7 @@
"eslint": "^9.23.0", "eslint": "^9.23.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"playwright": "^1.60.0",
"ts-jest": "^29.3.1", "ts-jest": "^29.3.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.2" "typescript": "^5.8.2"
+37
View File
@@ -0,0 +1,37 @@
WebGL Scene Assets
==================
- `wood_table_diff_1k.jpg`
- Source: Poly Haven, "Wood Table"
- URL: https://polyhaven.com/a/wood_table
- Author: Dimitrios Savva
- License: CC0
- `book/open-book-poly-pizza.glb`
- Source: Poly Pizza, "open book"
- URL: https://poly.pizza/m/4WPcl72i1_S
- Author: Justin Randall
- License: Creative Commons Attribution 3.0
- Notes: Real authored open-book GLB used as the visible book model.
- `book/open-book-poly-pizza-preview.jpg`
- Source: Poly Pizza, "open book" preview image
- URL: https://poly.pizza/m/4WPcl72i1_S
- Author: Justin Randall
- License: Creative Commons Attribution 3.0
Candidate model found during follow-up search:
- "Old Magical Book" by Akiko.Tomiyoshi on Sketchfab
- URL: https://sketchfab.com/3d-models/old-magical-book-326cf7653c7c4ec19d2672f5a7a33578
- License: Creative Commons Attribution
- Notes: The model description says it has page-flipping animation using bone and lattice modifiers.
- Blocker: Sketchfab's download API requires authenticated credentials, so it was not pulled into the repository automatically.
Candidate model checked and rejected for automated import:
- "Rigged book" by Jissse on Blend Swap
- URL: https://blendswap.com/blend/26504
- License: CC0
- Notes: The page lists a Blender rig with controllable pages.
- Blocker: The file download requires signing in; the unauthenticated response is a download page, not model bytes.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

+192
View File
@@ -1926,3 +1926,195 @@ body:not([data-game-running="true"]) #start_prompt {
.openai-setting { .openai-setting {
display: none; /* Hidden by default, shown when the relevant provider is selected */ display: none; /* Hidden by default, shown when the relevant provider is selected */
} }
body.webgl-mode {
background: #090705;
align-items: stretch;
justify-content: stretch;
}
#webgl_app {
position: fixed;
inset: 0;
overflow: hidden;
background: #090705;
}
#webgl_canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
#top_menu {
position: fixed;
z-index: 50;
top: 0;
left: 0;
right: 0;
height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
box-sizing: border-box;
color: rgba(246, 231, 201, 0.94);
background: linear-gradient(180deg, rgba(13, 10, 7, 0.88), rgba(13, 10, 7, 0.46));
border-bottom: 1px solid rgba(246, 231, 201, 0.18);
font-size: 16px;
line-height: 1;
}
#top_menu_title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
#top_menu_controls {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
#top_menu_controls button,
.modal-overview-row {
font-family: 'EB Garamond', serif;
font-size: 14px;
line-height: 1;
color: rgba(246, 231, 201, 0.92);
background: rgba(44, 28, 17, 0.72);
border: 1px solid rgba(246, 231, 201, 0.24);
border-radius: 4px;
padding: 6px 10px;
cursor: pointer;
}
#top_menu_controls button:hover,
.modal-overview-row:hover {
background: rgba(87, 55, 31, 0.78);
}
#modal_overview {
position: fixed;
z-index: 45;
top: 52px;
right: 14px;
width: 164px;
color: rgba(246, 231, 201, 0.9);
background: rgba(12, 9, 7, 0.58);
border: 1px solid rgba(246, 231, 201, 0.18);
border-radius: 6px;
padding: 8px;
box-sizing: border-box;
backdrop-filter: blur(10px);
}
.modal-overview-title {
font-size: 13px;
line-height: 1;
margin: 0 0 8px;
color: rgba(246, 231, 201, 0.68);
}
#modal_overview_list {
display: grid;
gap: 6px;
}
.modal-overview-row {
display: flex;
justify-content: space-between;
width: 100%;
text-align: left;
padding: 6px 8px;
}
.modal-overview-row span:last-child {
color: rgba(246, 231, 201, 0.62);
}
body.webgl-mode #book {
position: fixed;
z-index: 20;
inset: 38px 0 0;
width: 100vw;
height: calc(100vh - 38px);
max-width: none;
max-height: none;
background: transparent;
pointer-events: none;
transform: none;
opacity: 0;
}
body.webgl-mode #page_left,
body.webgl-mode #page_right {
pointer-events: none;
top: 10%;
bottom: auto;
height: 68vh;
max-height: 760px;
width: min(31vw, 500px);
background: #f2dfb8;
border: 0;
box-shadow: none;
opacity: 1;
mix-blend-mode: normal;
}
body.webgl-mode #page_left {
left: calc(50vw - min(33vw, 530px));
transform: none;
transform-origin: right center;
}
body.webgl-mode #page_right {
right: calc(50vw - min(33vw, 530px));
transform: none;
transform-origin: left center;
}
body.webgl-mode #lighting {
display: none;
}
@media (max-width: 780px) {
#modal_overview {
display: none;
}
#top_menu {
height: auto;
min-height: 42px;
gap: 8px;
padding: 6px 10px;
}
#top_menu_controls button {
padding: 6px 7px;
}
body.webgl-mode #book {
inset: 46px 0 0;
}
body.webgl-mode #page_left,
body.webgl-mode #page_right {
width: 44vw;
height: 66vh;
top: 12%;
}
body.webgl-mode #page_left {
left: 6vw;
}
body.webgl-mode #page_right {
right: 6vw;
}
}
+10 -3
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR' ERROR: 'ERROR'
}; };
const MODULE_CACHE_BUSTER = '20260516-scroll-window'; const MODULE_CACHE_BUSTER = '20260603-webgl-right-page-text';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/** /**
@@ -113,6 +113,7 @@ const ModuleLoader = (function() {
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 }, { id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
{ id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
{ id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 }, { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS { id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
@@ -883,12 +884,18 @@ const ModuleLoader = (function() {
return; return;
} }
await waitForProgressIndicatorsToExit(); await Promise.race([
waitForProgressIndicatorsToExit(),
new Promise(resolve => setTimeout(resolve, 700))
]);
// Set opacity to 0 to trigger the fade-out transition // Set opacity to 0 to trigger the fade-out transition
loadingOverlay.style.opacity = '0'; loadingOverlay.style.opacity = '0';
await waitForTransition(loadingOverlay, 'opacity'); await Promise.race([
waitForTransition(loadingOverlay, 'opacity'),
new Promise(resolve => setTimeout(resolve, 700))
]);
console.log('Module Loader: Removing overlay from DOM'); console.log('Module Loader: Removing overlay from DOM');
+7 -1
View File
@@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler'); super('ui-display-handler', 'UI Display Handler');
// Module dependencies // Module dependencies
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser']; this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser'];
// DOM elements // DOM elements
this.container = null; this.container = null;
@@ -170,6 +170,7 @@ class UIDisplayHandlerModule extends BaseModule {
// Get references to required modules using parent's getModule method // Get references to required modules using parent's getModule method
this.layoutRenderer = this.getModule('layout-renderer'); this.layoutRenderer = this.getModule('layout-renderer');
this.webglBookScene = this.getModule('webgl-book-scene');
this.playbackCoordinator = this.getModule('playback-coordinator'); this.playbackCoordinator = this.getModule('playback-coordinator');
this.gameConfig = this.getModule('game-config'); this.gameConfig = this.getModule('game-config');
this.localization = this.getModule('localization'); this.localization = this.getModule('localization');
@@ -355,6 +356,8 @@ class UIDisplayHandlerModule extends BaseModule {
* Initialize the UI containers * Initialize the UI containers
*/ */
initializeContainers() { initializeContainers() {
this.webglBookScene?.ensureShell?.();
// Check if the book container already exists // Check if the book container already exists
let bookContainer = document.getElementById('book'); let bookContainer = document.getElementById('book');
if (!bookContainer) { if (!bookContainer) {
@@ -526,7 +529,10 @@ class UIDisplayHandlerModule extends BaseModule {
this.createNotificationDialog(); this.createNotificationDialog();
console.log('UIDisplayHandler: All containers initialized'); console.log('UIDisplayHandler: All containers initialized');
this.webglBookScene?.adoptPageContent?.();
this.webglBookScene?.refreshModalOverview?.();
this.applyGameConfig(this.gameConfig?.getConfig?.()); this.applyGameConfig(this.gameConfig?.getConfig?.());
this.webglBookScene?.adoptPageContent?.();
this.applyTranslations(); this.applyTranslations();
this.measureStoryLineHeight(); this.measureStoryLineHeight();
this.setStoryOffset(0); this.setStoryOffset(0);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+605
View File
@@ -0,0 +1,605 @@
/**
* 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 = `
<div id="top_menu_title">AI Interactive Fiction</div>
<div id="top_menu_controls">
<button type="button" data-modal-target="options-modal">Options</button>
<button type="button" data-modal-target="credits_modal">Credits</button>
<button type="button" data-modal-target="story_popup_modal">Notice</button>
</div>
`;
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 = '<div class="modal-overview-title">Modals</div><div id="modal_overview_list"></div>';
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 = `<span>${modal.label}</span><span>${isOpen ? 'open' : 'closed'}</span>`;
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;
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebGL Book Lab</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #080604;
color: #eadbc2;
font-family: Georgia, "Times New Roman", serif;
}
#scene {
display: block;
width: 100vw;
height: 100vh;
}
#lab_menu {
position: fixed;
z-index: 10;
inset: 0 0 auto;
height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
box-sizing: border-box;
background: linear-gradient(180deg, rgba(13, 9, 6, 0.94), rgba(13, 9, 6, 0.58));
border-bottom: 1px solid rgba(214, 180, 125, 0.22);
pointer-events: none;
}
#lab_title {
font-size: 15px;
letter-spacing: 0;
color: #f1dec0;
}
#lab_status {
font-size: 13px;
color: rgba(241, 222, 192, 0.72);
}
</style>
</head>
<body>
<canvas id="scene" aria-label="Procedural book scene lab"></canvas>
<div id="lab_menu">
<div id="lab_title">Procedural Book Lab</div>
<div id="lab_status">standalone scene</div>
</div>
<script type="module" src="/js/webgl-book-lab.js"></script>
</body>
</html>