Add WebGL book scene checkpoint
This commit is contained in:
Generated
+48
@@ -32,6 +32,7 @@
|
||||
"eslint": "^9.23.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"playwright": "^1.60.0",
|
||||
"ts-jest": "^29.3.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
@@ -6266,6 +6267,53 @@
|
||||
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"eslint": "^9.23.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.1.9",
|
||||
"playwright": "^1.60.0",
|
||||
"ts-jest": "^29.3.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.2"
|
||||
|
||||
@@ -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.
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 |
@@ -1926,3 +1926,195 @@ body:not([data-game-running="true"]) #start_prompt {
|
||||
.openai-setting {
|
||||
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
@@ -24,7 +24,7 @@ const ModuleState = {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -113,6 +113,7 @@ const ModuleLoader = (function() {
|
||||
{ id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
|
||||
{ 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: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 },
|
||||
{ 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
|
||||
|
||||
@@ -883,12 +884,18 @@ const ModuleLoader = (function() {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForProgressIndicatorsToExit();
|
||||
await Promise.race([
|
||||
waitForProgressIndicatorsToExit(),
|
||||
new Promise(resolve => setTimeout(resolve, 700))
|
||||
]);
|
||||
|
||||
// Set opacity to 0 to trigger the fade-out transition
|
||||
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');
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
super('ui-display-handler', 'UI Display Handler');
|
||||
|
||||
// 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
|
||||
this.container = null;
|
||||
@@ -170,6 +170,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
|
||||
// Get references to required modules using parent's getModule method
|
||||
this.layoutRenderer = this.getModule('layout-renderer');
|
||||
this.webglBookScene = this.getModule('webgl-book-scene');
|
||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||
this.gameConfig = this.getModule('game-config');
|
||||
this.localization = this.getModule('localization');
|
||||
@@ -355,6 +356,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
* Initialize the UI containers
|
||||
*/
|
||||
initializeContainers() {
|
||||
this.webglBookScene?.ensureShell?.();
|
||||
|
||||
// Check if the book container already exists
|
||||
let bookContainer = document.getElementById('book');
|
||||
if (!bookContainer) {
|
||||
@@ -526,7 +529,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
this.createNotificationDialog();
|
||||
|
||||
console.log('UIDisplayHandler: All containers initialized');
|
||||
this.webglBookScene?.adoptPageContent?.();
|
||||
this.webglBookScene?.refreshModalOverview?.();
|
||||
this.applyGameConfig(this.gameConfig?.getConfig?.());
|
||||
this.webglBookScene?.adoptPageContent?.();
|
||||
this.applyTranslations();
|
||||
this.measureStoryLineHeight();
|
||||
this.setStoryOffset(0);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user