Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e87f935b8 | |||
| 7f60ce0d63 | |||
| 0f66dae4eb | |||
| c7364b0497 | |||
| 91b5999cd2 | |||
| 705d1ea6bf | |||
| 004c077181 | |||
| b0175b7cdc | |||
| 0e4d9e89d7 | |||
| 97f0b913be | |||
| 0e3e2abdb6 | |||
| b180637ea7 | |||
| 1e8defbb55 | |||
| 28d5e51c92 | |||
| 47af10d60c | |||
| e72594b3ff | |||
| dc2afcf831 | |||
| 6bd1f45362 | |||
| a845108c43 | |||
| ab194062bb | |||
| 8bb18fa201 | |||
| c19ebe3089 | |||
| ef358c5cfd | |||
| 97eab216b7 | |||
| e3d66686b9 | |||
| 623b42caf9 | |||
| ce8147b5b1 | |||
| 5a84923884 | |||
| 10bf23b10b | |||
| b41340151d | |||
| 171cafeb65 | |||
| fe51410a3b | |||
| d665a0f237 | |||
| 419691000c | |||
| a73dc5725f | |||
| 119cefd4bd | |||
| efd1e6cfff | |||
| 3e28d7db23 | |||
| 86b6fa0419 | |||
| c86a304364 | |||
| 7abd3387f3 | |||
| da37608197 | |||
| 53c24e4fae | |||
| 9695d48368 | |||
| 74ddd1de1c | |||
| 9434950826 | |||
| 7fc083fb58 | |||
| 7725ce9c73 | |||
| de81a7c5c5 | |||
| 1b593c8c7b | |||
| 777e39a650 | |||
| 081cfa9902 | |||
| 1b8c8f8bce | |||
| 431e305df9 | |||
| bc736513d4 | |||
| 9836c68ffa | |||
| 62215b280f | |||
| 326f812b22 | |||
| b734d83227 | |||
| 83ca095d54 | |||
| 0cb1e7c6f5 | |||
| 965be72ea4 | |||
| 0956d2ef1f | |||
| 925caa57bb | |||
| 67c0c4e7e3 | |||
| 13f8b60e20 | |||
| f634500121 | |||
| 874d360d22 | |||
| 32d2a6a15a | |||
| 83b30000da | |||
| ca38f9ce92 | |||
| 80d29ed2d2 | |||
| f00072282e | |||
| be1056b280 | |||
| 738e683c7b | |||
| ee14916661 | |||
| e88ab8c48b | |||
| fd608ba217 | |||
| ac382a6cac | |||
| a92822bc44 | |||
| 139086917e | |||
| 9f659f8f63 | |||
| ecc4413014 | |||
| 65dbbdd093 | |||
| fc38dca7cf | |||
| 467842ba0b | |||
| b5c2f9fa42 | |||
| ee641d2b91 | |||
| ae84eb8976 | |||
| ae8068ad8a | |||
| 44fb461eae | |||
| 444312351a | |||
| 248973fc77 | |||
| 5283f0007e | |||
| 5a5464e0b4 | |||
| a95ac9db50 | |||
| 4adf85b4d2 | |||
| 073be20dca | |||
| 552bf14626 | |||
| e5b00f7472 | |||
| 444acb6229 | |||
| e1396d44bb | |||
| 90308e4b1b | |||
| 5127bbc743 | |||
| bdec4590d2 | |||
| 199462442c |
@@ -6,6 +6,8 @@ export interface GameMetadata {
|
||||
version?: string;
|
||||
copyright?: string;
|
||||
language?: string;
|
||||
bookPageCount?: number;
|
||||
pageReserve?: number;
|
||||
}
|
||||
export interface GamePaths {
|
||||
mainGameFile: string;
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":";;;;;AA4DA,kCAIC;AAED,wCAsBC;AAED,4EAkBC;AAED,4CAYC;AA1HD,gDAAwB;AACxB,2BAAyD;AA+BzD,MAAM,YAAY,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD,SAAS,cAAc,CAAC,MAAkB;IACxC,OAAO;QACL,MAAM;QACN,MAAM,EAAE,OAAO;QACf,KAAK,EAAE;YACL,YAAY,EACV,MAAM,KAAK,KAAK;gBACd,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,MAAM,KAAK,OAAO;oBAClB,CAAC,CAAC,uBAAuB;oBACzB,CAAC,CAAC,+BAA+B;YACvC,KAAK,EAAE,cAAc;YACrB,GAAG,EAAE,eAAe;YACpB,MAAM,EAAE,eAAe;SACxB;QACD,QAAQ,EAAE;YACR,KAAK,EAAE,wBAAwB;YAC/B,MAAM,EAAE,eAAe;YACvB,QAAQ,EAAE,8BAA8B;YACxC,OAAO,EAAE,OAAO;YAChB,SAAS,EAAE,EAAE;YACb,QAAQ,EAAE,OAAO;SAClB;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,sBAA8B;IACxD,OAAO,cAAI,CAAC,UAAU,CAAC,sBAAsB,CAAC;QAC5C,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,YAAY,EAAE,sBAAsB,CAAC,CAAC;AACzD,CAAC;AAED,SAAgB,cAAc,CAAC,UAAkB,EAAE,MAAkB;IACnE,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,oBAAoB,YAAY,WAAW,MAAM,YAAY,CAAC,CAAC;QAC5E,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,YAAY,EAAE,MAAM,CAAC,CAA8B,CAAC;IAC3F,MAAM,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,KAAK,EAAE;YACL,GAAG,QAAQ,CAAC,KAAK;YACjB,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACxB;QACD,QAAQ,EAAE;YACR,GAAG,QAAQ,CAAC,QAAQ;YACpB,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;YAC1B,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,QAAQ,IAAI,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,QAAQ,CAAC,QAAQ;SACnF;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,gCAAgC,CAAC,MAAwB;IACvE,MAAM,WAAW,GAAG;QAClB,MAAM,CAAC,KAAK,CAAC,KAAK;QAClB,MAAM,CAAC,KAAK,CAAC,GAAG;QAChB,MAAM,CAAC,KAAK,CAAC,MAAM;QACnB,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QACzE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7E,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;QAC/E,MAAM,CAAC,KAAK,CAAC,SAAS;KACvB,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,WAAW,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,YAAY,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;YAC9B,IAAA,cAAS,EAAC,YAAY,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,gBAAgB,CAAC,MAAwB;IACvD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE;YACN,KAAK,EAAE,SAAS;YAChB,GAAG,EAAE,UAAU;YACf,MAAM,EAAE,UAAU;YAClB,MAAM,EAAE,UAAU;SACnB;KACF,CAAC;AACJ,CAAC"}
|
||||
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":";;;;;AA8DA,kCAIC;AAED,wCAsBC;AAED,4EAkBC;AAED,4CAYC;AA5HD,gDAAwB;AACxB,2BAAyD;AAiCzD,MAAM,YAAY,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD,SAAS,cAAc,CAAC,MAAkB;IACxC,OAAO;QACL,MAAM;QACN,MAAM,EAAE,OAAO;QACf,KAAK,EAAE;YACL,YAAY,EACV,MAAM,KAAK,KAAK;gBACd,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,MAAM,KAAK,OAAO;oBAClB,CAAC,CAAC,uBAAuB;oBACzB,CAAC,CAAC,+BAA+B;YACvC,KAAK,EAAE,cAAc;YACrB,GAAG,EAAE,eAAe;YACpB,MAAM,EAAE,eAAe;SACxB;QACD,QAAQ,EAAE;YACR,KAAK,EAAE,wBAAwB;YAC/B,MAAM,EAAE,eAAe;YACvB,QAAQ,EAAE,8BAA8B;YACxC,OAAO,EAAE,OAAO;YAChB,SAAS,EAAE,EAAE;YACb,QAAQ,EAAE,OAAO;SAClB;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,sBAA8B;IACxD,OAAO,cAAI,CAAC,UAAU,CAAC,sBAAsB,CAAC;QAC5C,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,YAAY,EAAE,sBAAsB,CAAC,CAAC;AACzD,CAAC;AAED,SAAgB,cAAc,CAAC,UAAkB,EAAE,MAAkB;IACnE,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,oBAAoB,YAAY,WAAW,MAAM,YAAY,CAAC,CAAC;QAC5E,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,YAAY,EAAE,MAAM,CAAC,CAA8B,CAAC;IAC3F,MAAM,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,KAAK,EAAE;YACL,GAAG,QAAQ,CAAC,KAAK;YACjB,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACxB;QACD,QAAQ,EAAE;YACR,GAAG,QAAQ,CAAC,QAAQ;YACpB,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;YAC1B,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,QAAQ,IAAI,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,QAAQ,CAAC,QAAQ;SACnF;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,gCAAgC,CAAC,MAAwB;IACvE,MAAM,WAAW,GAAG;QAClB,MAAM,CAAC,KAAK,CAAC,KAAK;QAClB,MAAM,CAAC,KAAK,CAAC,GAAG;QAChB,MAAM,CAAC,KAAK,CAAC,MAAM;QACnB,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QACzE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7E,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;QAC/E,MAAM,CAAC,KAAK,CAAC,SAAS;KACvB,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,WAAW,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,YAAY,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;YAC9B,IAAA,cAAS,EAAC,YAAY,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,gBAAgB,CAAC,MAAwB;IACvD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE;YACN,KAAK,EAAE,SAAS;YAChB,GAAG,EAAE,UAAU;YACf,MAAM,EAAE,UAAU;YAClB,MAAM,EAAE,UAAU;SACnB;KACF,CAAC;AACJ,CAAC"}
|
||||
@@ -0,0 +1,713 @@
|
||||
# WebGL 3D UI Specification And Implementation Plan
|
||||
|
||||
This document captures the agreed direction for the WebGL book UI. Later decisions override earlier ones. The purpose is to stop visual regressions and make future implementation work testable against an explicit contract.
|
||||
|
||||
## Current Goal
|
||||
|
||||
Build a beautiful, readable, extensible WebGL book interface for the interactive fiction UI.
|
||||
|
||||
The current stable milestone is the integrated 3D game UI at `http://localhost:3001/`: an open procedural book lying on a polished wooden table, lit by flickering candles, with game content typeset directly into texture-space canvases and applied to the actual top surfaces of the paper stacks.
|
||||
|
||||
The product goal is a procedural book UI that supports virtual scrolling, animated page flips, dynamic page stacks, and content backfilling across spreads.
|
||||
|
||||
## Current Implementation Snapshot
|
||||
|
||||
This section records the current state after the procedural book integration work.
|
||||
|
||||
- The active integrated scene is loaded through the game at `http://localhost:3001/`.
|
||||
- `public/webgl-book-lab.html` remains a reference/prototype file, not the primary implementation target.
|
||||
- The current test server is expected to be the single Node process listening on port `3001`.
|
||||
- The procedural book model lives in `public/js/procedural-book-model.js`.
|
||||
- The WebGL lab integration lives in `public/js/webgl-book-lab.js`.
|
||||
- The old fixed-box book has been removed from the lab scene.
|
||||
- The new procedural book is generated from fixed page dimensions, a calculated spine arc, cover panels, hinge panels, page-stack spline lines, stack bodies, and animated flipping page geometry.
|
||||
- Book page count is clamped to 40-500 pages.
|
||||
- Page count changes in 10-page bundle increments.
|
||||
- Each visible bundle line represents 10 pages.
|
||||
- Reading progress controls the left/right split of page bundles along the spine arc.
|
||||
- The spine grows with page count and pushes the covers outward; the covers do not shrink to pay for spine growth.
|
||||
- `PAGE_SPLINE_LENGTH` must match `PAGE_WIDTH`.
|
||||
- Cover width is derived from page width plus cover overhang.
|
||||
- The spine bottom is aligned to the table plane with only a tiny render clearance.
|
||||
- The book navigation controls are the bottom media-style navigation controls: return to beginning, flip backward, page-position scrollbar, flip forward, and go to end.
|
||||
- Slow page flips and fast 10-page transitions are implemented.
|
||||
- Fast transitions run overlapping flip animations before shifting the book by one bundle.
|
||||
- The readable page content belongs on the visible top cap of the paper stacks.
|
||||
- No separate floating reading-surface overlay should be added on top of the stack cap.
|
||||
- Extreme progress states must use the same top-cap/material topology as normal stack states.
|
||||
- Synthetic hair/support pages used for empty-side extremes must not receive page content if they are not the actual readable stack top.
|
||||
- The spine arc and cloth spine must not receive page-content textures.
|
||||
- Page-content textures are high-resolution canvas textures with hardcover-style margins.
|
||||
- The current page canvases are deliberately smoother than the old noisy page texture.
|
||||
- The animated flipping page uses its own clean paper material (`flipPageSurface`) instead of the resting page material, so motion does not reveal stationary paper grain, stack-line textures, content textures, or construction patterns.
|
||||
- Page-stack side lines are generated as textures, not as free-standing line meshes.
|
||||
- Stack side textures should have consistent orientation and line count on all visible stack sides.
|
||||
- Cover, hinge, spine base, and cover edge materials use leather-style materials with procedural leather color and normal maps.
|
||||
- Cloth spine head/end-bands are real small raised meshes with their own woven red/ivory texture material, not painted stripes on the spine cloth shader.
|
||||
- The book, table, candles, and flipping pages have `castShadow` and `receiveShadow` disabled.
|
||||
- Three.js/OpenGL primitive shadows must stay disabled.
|
||||
- Candle shadows are implemented in the custom table/book shader path, not through Three.js shadow maps.
|
||||
- Scene SSAO is present as a postprocessing pass. The procedural book, head/end-bands, and flip page are normal scene participants unless a mesh is explicitly added to `aoExcludedObjects`; flames and glow sprites remain excluded.
|
||||
- The table reflection path remains active and should include the book and candles.
|
||||
- The custom book material shader includes a small local indirect/bounce-light term for book surfaces. This is not emissive; it is multiplied by material albedo so paper, leather, and head/end-band colors remain distinct while shadowed side faces stay readable.
|
||||
- Temporary screenshots and generated debug images are not product assets unless explicitly promoted.
|
||||
|
||||
## Page-Flow Architecture
|
||||
|
||||
The 3D book is the primary display. A secondary 2D DOM view may be driven from the same content,
|
||||
but it is never the source of truth and must not own playback decisions. No page content is
|
||||
generated by ad hoc scene code.
|
||||
|
||||
### Single ownership
|
||||
|
||||
There is exactly one playback owner: `book-playback-timeline`. It owns the complete content
|
||||
lifecycle for story text and is the only module allowed to sequence it:
|
||||
|
||||
```
|
||||
prepare (pagination + textures + prewarm)
|
||||
-> activate (make the target spread the visible spread)
|
||||
-> reveal (animate the new block's text in)
|
||||
-> flip (turn the page when a spread boundary is crossed)
|
||||
```
|
||||
|
||||
- `ui-display-handler` and `sentence-queue` are clients of the owner. They call
|
||||
`book-playback-timeline.playSentence` (live) and `prepareSentence` (lookahead). They contain no
|
||||
flip, reveal, or spread-transition logic of their own.
|
||||
- `book-pagination` owns page/spread construction, page metadata, widows/orphans/hyphenation,
|
||||
image placement, and explicit blank/title/body page records. It does not decide playback timing.
|
||||
- `book-texture-renderer` owns drawing final page canvases and computing reveal-region coordinates
|
||||
and per-region timing metadata. It is a pure renderer: it does **not** run a playback clock, does
|
||||
**not** decide when reveals start/finish, and does **not** trigger flips. Reactive redraws keyed
|
||||
off pagination events are forbidden; the owner asks it to draw.
|
||||
- `webgl-page-cache` is the single texture/canvas cache: persistent canvases, memory canvases,
|
||||
prepared reveal plans, prepared GPU textures, resident VRAM textures, the blank texture, and
|
||||
visible texture bindings. No other module may keep a parallel cache.
|
||||
- `webgl-book-scene` (implemented in `webgl-book-lab.js`) owns the Three.js scene, materials,
|
||||
geometry, pointer projection, page-flip meshes, the single reveal clock, and consuming page
|
||||
texture records. It must not become a second page cache and must not decide playback order.
|
||||
|
||||
### Single reveal clock
|
||||
|
||||
Reveal timing has exactly one authority: the scene render loop. It advances reveal progress,
|
||||
freezes it during a physical flip, and emits `webgl-book:reveal-committed` when a side's reveal
|
||||
finishes. No module runs a second `setTimeout`/`requestAnimationFrame` reveal clock in parallel.
|
||||
The texture renderer supplies region timings; it does not measure their elapsed time.
|
||||
|
||||
### Owner-to-scene command channel
|
||||
|
||||
The owner drives the scene through the registered scene command interface obtained from the module
|
||||
registry (`webgl-book-scene`), or through the formal `webgl-book:*` events listed below. The owner
|
||||
must never reach into `window.BookLabDebug` — that object is a debug/inspection surface only and is
|
||||
not part of any production control path. Production code must not throw because a debug global is
|
||||
missing.
|
||||
|
||||
### Problem states are surfaced, never hidden
|
||||
|
||||
- A missing persistent page canvas during prewarm is a `db-cache-miss`.
|
||||
- A missing source or required back texture before a page flip is a `flip-source-texture-missing`
|
||||
or `flip-back-texture-missing`.
|
||||
- These appear in `webglPageCacheProblems` and must not be silently fixed by borrowing unrelated
|
||||
visible stack textures. Surfacing a problem must not abort live reading: a transient miss degrades
|
||||
(blank/last-good texture) and is logged, rather than throwing out of the playback path.
|
||||
|
||||
## Event Surface
|
||||
|
||||
The owner controls the scene through the scene command interface (preferred for direct,
|
||||
ordered calls) and through these formal events. Each event has one producer and stable meaning:
|
||||
|
||||
- `webgl-book:page-texture-records` (owner/renderer -> scene): publishes explicit page texture
|
||||
records for a spread, carrying `phase` (`prepare`|`activate`) and per-side `visibility`.
|
||||
- `webgl-book:page-reveal-start` (owner -> scene): starts the scene reveal clock for a block.
|
||||
- `webgl-book:page-reveal-fast-forward` (owner -> scene): accelerates reveal timing without
|
||||
replacing the page pipeline.
|
||||
- `webgl-book:reveal-committed` (scene -> owner): a page-side reveal completed. The owner — not the
|
||||
scene — decides whether a flip follows.
|
||||
- `webgl-book:request-page-flip` (owner -> scene): requests a physical page flip.
|
||||
- `webgl-book:page-flip-started`, `webgl-book:page-flip-near-end`, `webgl-book:page-flip-finished`
|
||||
(scene -> owner): the physical flip lifecycle, each carrying the resolved `targetSpread`.
|
||||
|
||||
The scene reacts to these events; it does not originate flip decisions from `reveal-committed`.
|
||||
|
||||
Deprecated or forbidden contracts:
|
||||
|
||||
- `webgl-book:page-canvases` is obsolete; use `webgl-book:page-texture-records`.
|
||||
- `preloadOnly` and `allowFutureUnrendered` boolean flags are obsolete; use explicit `phase` and
|
||||
`visibility` values.
|
||||
- The legacy `ownsPageFlipCommit` toggle and the `book-pagination:spread-updated`-driven reveal/flip
|
||||
path in `book-texture-renderer` and the scene are removed. There is no second playback path to
|
||||
gate, so no gating flag exists.
|
||||
|
||||
## Non-Negotiable Workflow Rules
|
||||
|
||||
- Do not continue visual coding without a concrete plan for the current sprint.
|
||||
- Before tuning a visual feature, first prove it is active.
|
||||
- Debug views must prove the feature in isolation.
|
||||
- Composite views must prove the feature affects the final image.
|
||||
- If a feature is broken or inactive, do not treat it as an intensity/tuning problem.
|
||||
- Do not replace a working feature while implementing a new one unless the replacement is proven better in debug and composite views.
|
||||
- Do not commit untested visual changes.
|
||||
- Commit stable major changes before starting a new topic.
|
||||
- Do not leave orphaned dev servers, Node processes, Playwright processes, or browser automation processes.
|
||||
- Do not regenerate expensive textures in memory during page load once the design asset is stable. Generate them once, save them to disk, and load static assets.
|
||||
- Do not use fake visual shortcuts once a shader or proper rendering path has been agreed.
|
||||
- Do not introduce fallback visual cheats when the agreed task fails. If the real technique is not working, stop, document the failure, and investigate the real technique.
|
||||
- Do not let a fallback or diagnostic layer become part of the final composite unless it is explicitly approved as a permanent art-direction layer.
|
||||
- When screenshot capture fails, fix the capture/test tooling before continuing visual iteration.
|
||||
- Do not enable `renderer.shadowMap.enabled`.
|
||||
- Do not set any mesh `castShadow` or `receiveShadow` flag to `true`.
|
||||
- Do not use Three.js primitive shadow maps as a fallback for candle shadows.
|
||||
- Candle shadows must remain a custom shader responsibility.
|
||||
|
||||
## Repository And Branch Rules
|
||||
|
||||
- Work is on branch `webgl`.
|
||||
- Do not stage or commit unrelated `.env` changes.
|
||||
- Temporary screenshots are not product assets and should not be committed unless explicitly requested.
|
||||
- Generated static visual assets may be committed when they are part of the scene contract.
|
||||
- Documentation changes must use `apply_patch`.
|
||||
|
||||
## Scene Structure
|
||||
|
||||
The final UI module must remain compatible with the rest of the project, but its rendered layout changes from a flat static page into a layered 3D interface.
|
||||
|
||||
Required structure:
|
||||
|
||||
- One WebGL canvas as the main scene.
|
||||
- One top menu line over the canvas.
|
||||
- One modal overview layer over the canvas.
|
||||
- Existing modal behavior must remain compatible with the rest of the app.
|
||||
- The 3D book pages must display the actual app content as dynamic textures, not overlays placed above the model.
|
||||
|
||||
During visual development:
|
||||
|
||||
- Use an independent standalone page for the 3D scene.
|
||||
- The standalone page must not be burdened by the app loader, modal system, or unrelated runtime while the visual design is being finalized.
|
||||
- The standalone scene must remain modular enough to later integrate back into the actual app shell.
|
||||
|
||||
## Book Requirements
|
||||
|
||||
### Current Milestone
|
||||
|
||||
- The book is procedural unless a suitable real model is found and integrated correctly.
|
||||
- The book must look like a real open book, not a thin folio.
|
||||
- The book must lie flat and straight on the table.
|
||||
- The lower book edge should run parallel to the screen edge in the default reading camera.
|
||||
- The camera angle must be shallow enough for readability, not steep overhead.
|
||||
- The book must show the two current app pages as dynamic page textures.
|
||||
- The page aspect ratio and content must mirror the original book display as closely as possible.
|
||||
- The right page must not be mirrored or rotated incorrectly.
|
||||
- Text must be crisp enough for reading.
|
||||
- Page texture resolution must be high enough that the projected text remains crisp at intended camera distances.
|
||||
- The old UI/html creation module must be kept for reference while the new module replaces it.
|
||||
- The open book is made from named parts:
|
||||
- Spine: the central cloth/red arc and supporting center area.
|
||||
- Hinge: the angled transition from spine top height to cover/table height.
|
||||
- Cover: the main leather cover panel that supports each page stack.
|
||||
- Cover overhang: the small cover border extending beyond the paper stack.
|
||||
- The hinge width is the width required to descend from spine-top height to spine-bottom/table height at the chosen 45 degree hinge angle.
|
||||
- The cover starts at spine-top height near the hinge and descends until its outer edge touches the table.
|
||||
- The cover must extend under the page stack by the same overhang along book width as along book depth.
|
||||
- The paper stack must have closed bottom geometry where needed; it must not reveal empty inside faces from below.
|
||||
- The paper stack top is the readable content surface.
|
||||
- At progress `0.00`, `0.03`, `0.07`, and other extreme values, the surface topology and material assignment must remain consistent.
|
||||
- The right page must not become a different material or broken cap merely because the left side has no full stack yet.
|
||||
|
||||
### Later Product Goal
|
||||
|
||||
The book should become a dynamic procedural object:
|
||||
|
||||
- Both left and right pages are used for content.
|
||||
- Virtual scrolling drives page content.
|
||||
- The last two spreads are backfilled with virtual content.
|
||||
- Page flips animate between spreads.
|
||||
- When the user scrolls far through history, multiple quick page animations can occur.
|
||||
- The left stack height grows as reading progresses.
|
||||
- The right stack height shrinks or remains tied to the remaining book thickness.
|
||||
- Page stack thickness must visibly change as a progress marker.
|
||||
- The right side begins as the thicker unread portion.
|
||||
- The left side grows from a few pages toward parity with the right stack.
|
||||
- Individual pages can animate between stacks.
|
||||
- The architecture must allow page geometry, page textures, stack heights, and flip animation to be controlled separately.
|
||||
|
||||
## Page Texture Requirements
|
||||
|
||||
- Page content must be rendered into textures applied to the actual page geometry.
|
||||
- No separate reading-surface overlay on top of the book model.
|
||||
- The visible top cap of the paper stack is the page display surface.
|
||||
- Do not add a coincident floating page mesh over the stack cap to display content; that creates z-fighting risk and violates the intended architecture.
|
||||
- If the page content is wrong at an extreme progress state, fix the stack-cap topology or material assignment, not by adding a second page surface.
|
||||
- The left page texture must contain the full left page of the old layout.
|
||||
- The right page texture must contain the dynamic typeset text from the original module.
|
||||
- Text and lines must respect the original page proportions.
|
||||
- Texture capture/generation must not silently crop the content.
|
||||
- The page texture pipeline must support future virtualized content.
|
||||
- Page content should use typical hardcover novel margins:
|
||||
- Larger inner/gutter margin.
|
||||
- Smaller outer margin.
|
||||
- Comfortable top margin.
|
||||
- Larger bottom margin.
|
||||
- The DOM/canvas source used for page content must have the same aspect ratio and orientation as the physical page surface.
|
||||
- Page textures must be smooth enough that projected content does not reveal unwanted construction lines, stack lines, or noisy paper grain.
|
||||
- Neutral paper texture and content-page texture should have compatible filtering, mipmapping, and anisotropy.
|
||||
- Spine arc, hinge, cloth, and support-strip materials must not accidentally receive page-content texture.
|
||||
- The animated flipping page must use a clean paper material separate from resting/content pages. It should keep scene lighting, custom book shadows, and local bounce light, but should not use the procedural paper color/normal pattern that can create visible moving grain or shader artifacts.
|
||||
|
||||
### Text Sharpness Notes
|
||||
|
||||
- Current visible page content is rasterized into high-resolution canvas textures (`pageTextureWidth = 3200`) and then sampled on curved/angled 3D geometry.
|
||||
- Browser canvas text antialiasing, mipmap selection, linear filtering, anisotropy, page curvature, postprocessing, and viewing distance all affect perceived sharpness. This is not just a Windows font-rendering issue.
|
||||
- A single raster page texture cannot be perfectly sharp from every distance. Increasing the canvas to 4096 or 8192 can improve close views but costs memory and still depends on mipmap/filter behavior.
|
||||
- SDF means signed distance field: glyph edges are encoded as distances in a texture and reconstructed in the shader, allowing cleaner scalable edges than ordinary raster text.
|
||||
- MSDF means multi-channel signed distance field: edge distances are encoded across color channels, preserving corners and serifs better than single-channel SDF. MSDF is the better future path if page typography must stay crisp across camera distances.
|
||||
- SDF/MSDF text would require a separate text layout/rendering path or an MSDF font atlas. It should not be mixed into this sprint unless the raster canvas approach is proven insufficient.
|
||||
|
||||
## Camera Requirements
|
||||
|
||||
Default reading camera:
|
||||
|
||||
- The book lower edge is parallel to the screen edge.
|
||||
- The angle is slightly oblique, natural, and less steep than an overhead view.
|
||||
- The camera is close enough to make good use of the canvas.
|
||||
- The pages remain readable.
|
||||
- Candles remain visible as scene context without stealing focus.
|
||||
|
||||
Interactive camera controls:
|
||||
|
||||
- Mouse controls angle/orbit.
|
||||
- WASD moves the camera target through the scene.
|
||||
- Mouse wheel zooms.
|
||||
- Controls must not make the book drift or animate annoyingly when idle.
|
||||
|
||||
## Table Requirements
|
||||
|
||||
- The table surface is polished dark wood.
|
||||
- It must reflect the book, candles, flames, and environment.
|
||||
- Reflection must be physically plausible for a flat polished surface.
|
||||
- Reflections must not be offset in a way that breaks the mirror illusion.
|
||||
- Table wood remains visible; reflection strength must not drown it out.
|
||||
- The table uses a subtle normal map to avoid perfect mirror flatness.
|
||||
- The normal map must be subtle, not a large wobble or warped surface.
|
||||
- Dust and fingerprint/grease maps are separate concepts:
|
||||
- Dust is tiny particles that slightly catch specular light.
|
||||
- Grease/fingerprints are smears that affect reflectivity/roughness.
|
||||
- Dust must not look like breadcrumbs, paint, or a color overlay.
|
||||
- Grease/fingerprint marks must be filled, small relative to the table, and plausible.
|
||||
- Fingerprints should suppress dust where fingers wiped the surface.
|
||||
- Dust and grease must influence reflection/specular behavior, not merely base color.
|
||||
- Static disk assets should be used for stable table maps.
|
||||
|
||||
## Environment Reflection Requirements
|
||||
|
||||
- The generated room/environment image is a spherical/equirectangular reflection map.
|
||||
- It must be sampled as a 360 degree environment, not projected like a plane.
|
||||
- The spherical orientation must be correct: floorboards or room features must not appear in nonsensical directions.
|
||||
- The environment reflection applies to the table surface only unless explicitly intended elsewhere.
|
||||
- It must not incorrectly reflect on the book cover.
|
||||
- The environment map can contribute to candlelit ambient tone, but it must not become fake visible room geometry unless explicitly designed.
|
||||
|
||||
## Candle Requirements
|
||||
|
||||
There are three candles on the table.
|
||||
|
||||
Placement:
|
||||
|
||||
- Candles are asymmetrically placed.
|
||||
- Candles have different heights.
|
||||
- One candle is upper left.
|
||||
- One candle is upper right.
|
||||
- One candle is lower right.
|
||||
- Candles must sit on the table, not float or sink.
|
||||
- Candle flames must sit on the wicks, not inside the wax body.
|
||||
|
||||
Geometry:
|
||||
|
||||
- Candle bodies are cylindrical, not cut-off cones.
|
||||
- Wicks are visible and correctly positioned.
|
||||
- Flames use two-layer teardrop geometry:
|
||||
- Hot white/yellow core.
|
||||
- Transparent orange outer flame.
|
||||
|
||||
Flame shader:
|
||||
|
||||
- Animated noise/displacement.
|
||||
- Alpha falloff.
|
||||
- Gradient from blue/dark wick base to yellow core to orange tip.
|
||||
- Subtle flame movement drives light motion and shadow motion.
|
||||
|
||||
Wax shader:
|
||||
|
||||
- Semi-translucent wax.
|
||||
- Simulated subsurface scattering.
|
||||
- Stronger light-dependent backscatter near the flame.
|
||||
- Soft glow through the upper wax body.
|
||||
- The wax material should be flame-aware.
|
||||
- Candle reflection should preserve the wax look as much as possible in the mirror render.
|
||||
|
||||
## Lighting Requirements
|
||||
|
||||
- There is no window or other white external light source.
|
||||
- Candles are the only direct light sources.
|
||||
- The scene can include low warm ambient light representing candlelight reflected by room walls.
|
||||
- Candle point lights must be positioned at the animated flame positions in 3D space.
|
||||
- Lights should move subtly with the flames.
|
||||
- Each candle light must affect all relevant objects unless deliberately excluded for a documented reason.
|
||||
- The lighting on the book must be attributable to the candle lights and ambient candle bounce.
|
||||
- No unexplained fake light patches.
|
||||
|
||||
## Shadow Requirements
|
||||
|
||||
This project does not use Three.js/OpenGL primitive shadow maps for the book, table, or candles. Shadows are owned by the custom shader pipeline.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Candle cast shadows must exist in the final composite.
|
||||
- All three candle bodies must cast visible shadows.
|
||||
- All three candle light/flame positions must participate.
|
||||
- The book must cast shadows onto the table from all three candles.
|
||||
- The paper stacks, visible page tops, covers, hinges, spine, candles, wax bodies, and table must use the custom shader lighting/shadow path where relevant.
|
||||
- The candle shadows must respect wax translucency conceptually: wax transmission should soften/reduce the shadow rather than creating hard opaque cylinder shadows.
|
||||
- Shadows should be soft and believable, not hard-edged cones or arbitrary blobs.
|
||||
- Shadows must move subtly with the animated flame/light positions.
|
||||
- Contact AO is not a substitute for candle cast shadows.
|
||||
- Reflections are not a substitute for candle cast shadows.
|
||||
- SSAO is not a substitute for candle cast shadows.
|
||||
- Candle shadows must not be weakened, removed, or repurposed while working on SSAO.
|
||||
- `renderer.shadowMap.enabled` must stay `false`.
|
||||
- `castShadow` and `receiveShadow` must stay `false` on scene meshes.
|
||||
|
||||
Debug proof:
|
||||
|
||||
- `tableDebug=shadow` must clearly show shadow contribution from all three candles.
|
||||
- The shadow debug must show all three candle bodies casting shadows from the relevant flame/light positions.
|
||||
- The final composite must visibly include those shadows.
|
||||
- If the debug view shows shadows but the composite does not, the feature is not complete.
|
||||
|
||||
Implementation note:
|
||||
|
||||
- `tableDebug=shadow` must remain dedicated to candle cast-shadow proof.
|
||||
- If SSAO conflicts with custom candle shadows or page readability, SSAO loses until it is redesigned.
|
||||
|
||||
## Ambient Occlusion Requirements
|
||||
|
||||
AO must be separated conceptually from cast shadows.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- AO darkens tight contact and crevice regions.
|
||||
- Candle bottoms should have local contact occlusion.
|
||||
- The book/table contact should have local occlusion.
|
||||
- AO should not appear as broad painted darkness.
|
||||
- AO should not treat flames or glow sprites as solid occluders.
|
||||
- The book should be an equal participant in AO computations.
|
||||
- Candle contact AO must come from the real AO solution, not from a hand-authored fallback masquerading as AO.
|
||||
- Analytic contact darkening is not accepted as the solution for AO.
|
||||
- The fallback analytic contact/shadow layer must be removed completely before continuing SSAO work.
|
||||
|
||||
Current status:
|
||||
|
||||
- Scene-level SSAO has been added as a Three.js `SSAOPass`.
|
||||
- Flames and glow sprites are excluded from AO.
|
||||
- The procedural book and animated flip page are currently excluded from AO because the previous AO interaction made the book look inset/incorrect and excluded the top page from the effect.
|
||||
- `tableDebug=ao` proves the pass is wired, but the current AO result is not the accepted final AO design.
|
||||
- AO work is paused until the book material/topology integration is stable.
|
||||
|
||||
Debug proof:
|
||||
|
||||
- `tableDebug=ao` should show scene-level SSAO.
|
||||
- `tableDebug=contact` is deprecated with the analytic fallback. If retained temporarily during cleanup, it must be labeled as deprecated diagnostic output and must not affect the final composite.
|
||||
- `tableDebug=shadow` must show cast shadows only.
|
||||
- Debug views must be unambiguous.
|
||||
- The final composite must show the intended contribution.
|
||||
|
||||
SSAO investigation requirements:
|
||||
|
||||
1. Establish what the chosen SSAO/GTAO/HBAO implementation is supposed to compute.
|
||||
2. Identify its required inputs: depth, normals, camera projection, radius, scale, falloff, render target size, and pass order.
|
||||
3. Verify that the scene actually provides those inputs correctly.
|
||||
4. Verify that the AO pass sees the table, book, and candle wax bodies.
|
||||
5. Verify that flames and glow sprites are excluded from occlusion.
|
||||
6. Determine why the current `SSAOPass` output is nearly white and visually weak.
|
||||
7. Fix the root cause before tuning intensity.
|
||||
8. Prove the fixed AO in `tableDebug=ao`.
|
||||
9. Prove the fixed AO in the normal composite.
|
||||
10. Only then decide whether Three.js `SSAOPass` is sufficient or whether a custom GTAO/HBAO-style pass is needed.
|
||||
|
||||
Known SSAO failure hypotheses to test:
|
||||
|
||||
- AO radius may be wrong for the scene scale.
|
||||
- The pass may lack useful normal data for the current materials/geometries.
|
||||
- The table, book, or candles may be positioned or scaled such that the depth differences are too small for the current AO parameters.
|
||||
- Postprocessing pass order or output mode may dilute the AO before it reaches the final image.
|
||||
- Tone mapping/exposure may wash out AO.
|
||||
- The table shader and reflection composite may overwrite or hide AO contribution.
|
||||
- Flame/glow exclusion may be correct, but wax/book/table inclusion must be verified.
|
||||
- The debug output may be too low contrast to judge without a calibrated visualization.
|
||||
|
||||
## Reflection Requirements
|
||||
|
||||
Table reflections:
|
||||
|
||||
- Use a real planar reflection camera/mirror render path.
|
||||
- The reflection must include book, candles, wax bodies, wicks, flames, and environment contribution where appropriate.
|
||||
- Reflected flames should be smaller/warmer and partly occluded by reflected wax bodies.
|
||||
- Reflections must be crisp enough for the scene quality target.
|
||||
- Reflection render target resolution should favor quality over performance.
|
||||
- Anti-aliasing or higher render target resolution should be used if reflections look jagged.
|
||||
|
||||
Modern reflection note:
|
||||
|
||||
- A manual mirrored camera is acceptable only if alignment is correct.
|
||||
- A proper oblique reflection matrix/clip plane may be preferred for robust modern planar reflection.
|
||||
- If reflection alignment regresses, investigate the reflection camera/projection math first.
|
||||
|
||||
## Anti-Aliasing And Image Quality
|
||||
|
||||
- Image quality is prioritized over performance for this scene.
|
||||
- The target hardware assumption is strong enough for three candles and high-quality shader work.
|
||||
- The scene should use high-quality anti-aliasing for the main render and reflection render.
|
||||
- Text on pages must remain crisp.
|
||||
- Shader and render target choices must avoid visible jaggedness, especially after adding SSAO or postprocessing.
|
||||
- If postprocessing causes aliasing, fix the pass order/resolution rather than accepting degradation.
|
||||
|
||||
## Debug Views
|
||||
|
||||
The standalone scene should support debug query modes:
|
||||
|
||||
- `tableDebug=shadow`: candle cast-shadow contribution.
|
||||
- `tableDebug=ao`: scene-level SSAO.
|
||||
- `tableDebug=normal`: table normal map.
|
||||
- `tableDebug=dust`: dust map/effect.
|
||||
- `tableDebug=grease`: grease/fingerprint map/effect.
|
||||
- `tableDebug=room`: environment reflection contribution.
|
||||
- `tableDebug=scene`: planar scene reflection.
|
||||
- `tableDebug=mask`: table reflection mask.
|
||||
|
||||
Deprecated:
|
||||
|
||||
- `tableDebug=contact`: removed/deprecated. It must not return as a final-composite fallback.
|
||||
|
||||
Debug views must be visually meaningful. A debug view that is too subtle to interpret is not useful proof.
|
||||
|
||||
## Testing And Verification Requirements
|
||||
|
||||
Before reporting a visual feature as complete:
|
||||
|
||||
1. Run static/regression checks.
|
||||
2. Run the build.
|
||||
3. Capture a debug screenshot proving the feature exists in isolation.
|
||||
4. Capture a normal composite screenshot proving it affects the final image.
|
||||
5. Inspect the screenshots visually.
|
||||
6. Check that no rogue browser/Node/Playwright processes were left behind.
|
||||
7. Report honestly what is proven and what remains weak.
|
||||
|
||||
Screenshot tooling:
|
||||
|
||||
- Use the in-app browser tool when available.
|
||||
- If using Playwright, use one browser instance and close it in `finally`.
|
||||
- Use bounded timeouts.
|
||||
- If screenshot readback stalls, fix the capture method before continuing visual iteration.
|
||||
- Do not start multiple servers or leave orphaned processes.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0: Stabilize Current Work State
|
||||
|
||||
1. Stop all scene code changes.
|
||||
2. Keep this specification as the governing document.
|
||||
3. Do not commit any visual change until the current regression is fixed and tested.
|
||||
4. Preserve unrelated `.env` changes unstaged.
|
||||
5. Remove or ignore temporary screenshot files unless needed for explicit review.
|
||||
|
||||
### Phase 1: Procedural Book Integration
|
||||
|
||||
Goal: replace the old fixed-box book in `webgl-book-lab.html` with the procedural book model while preserving candle lighting, table reflection, and shader-owned shadows.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Import `createProceduralBookModel` into the WebGL lab.
|
||||
2. Place the procedural book so the spine bottom touches the table plane with minimal render clearance.
|
||||
3. Add top-bar controls for progress, page count, backward, forward, fast backward, and fast forward.
|
||||
4. Keep all book parts participating in custom candle shader lighting.
|
||||
5. Keep all Three.js primitive shadow flags disabled.
|
||||
6. Preserve table reflection and candle rendering.
|
||||
7. Ensure the right page/top cap is present at all progress values.
|
||||
8. Ensure the readable page content projects onto the stack top cap.
|
||||
9. Ensure spine arc/support/cloth materials do not receive page content.
|
||||
10. Run checks and build.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- The old book is gone from the lab scene.
|
||||
- The procedural book is visible, correctly placed on the table, and controllable.
|
||||
- Top page content appears on the actual stack top, not on a hovering overlay.
|
||||
- Extreme progress values render with the same topology/material rules as normal values.
|
||||
- No OpenGL shadow flags are enabled.
|
||||
|
||||
### Phase 2: Procedural Book Geometry And Solver
|
||||
|
||||
Goal: keep the book shape physically plausible and stable across progress/page-count values.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Keep page width, page depth, and page spline length consistent.
|
||||
2. Use deterministic line generation for page splines.
|
||||
3. Keep fixed segment lengths whose sum equals page width.
|
||||
4. Use shorter page-line segments near the spine and longer segments toward the page edge.
|
||||
5. Make every line follow the closest legal underlying layer:
|
||||
- First layer follows spine/hinge/cover profile and may touch it.
|
||||
- Later layers follow the paper line below and keep one bundle spacing.
|
||||
6. Keep the spine arc spacing proportional to page bundle count.
|
||||
7. Keep the spine as small as needed for 40 pages and as large as needed up to the capped maximum.
|
||||
8. Keep page count capped at 500 pages.
|
||||
9. Keep cover width independent of spine growth.
|
||||
10. Ensure stack bodies have closed sides, bottom where needed, and correct top cap normals.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Left and right cover lengths remain the same even when stack sizes differ.
|
||||
- Page widths remain the same on thick and thin stacks.
|
||||
- No final line segment runs backward.
|
||||
- The top caps face outward/upward and light correctly.
|
||||
- The first and last page states remain valid.
|
||||
- No stack body exposes an unintended hollow interior.
|
||||
|
||||
### Phase 3: Page Content And Materials
|
||||
|
||||
Goal: make the book read as a real leather-bound book with readable page content.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Generate page canvas textures at the correct page aspect ratio.
|
||||
2. Use hardcover-style margins.
|
||||
3. Keep page content on stack top caps.
|
||||
4. Keep neutral paper and content page textures smooth and consistently filtered.
|
||||
5. Generate stack side lines as textures.
|
||||
6. Keep stack side texture orientation consistent across front, back, left, and right sides.
|
||||
7. Use procedural leather color and normal maps for cover, hinge, spine base, and cover edge.
|
||||
8. Round the edge impression of cover panels without introducing open faces.
|
||||
9. Use a dedicated clean paper material for animated flipping pages.
|
||||
10. Verify cover, hinge, spine base, cover edge, paper side, paper top, flip page, head/end-band, and cloth spine material groups.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Page content is readable and correctly oriented.
|
||||
- Stack side lines are not free-standing meshes.
|
||||
- Stack side lines meet at corners without visible mismatch.
|
||||
- The visible page surface is smooth and does not reveal construction-line artifacts.
|
||||
- The animated flipping page remains clean during motion and does not show resting-page paper grain, page-content texture, or stack-side line patterns.
|
||||
- Leather parts look like real cover geometry, not flat orange planes.
|
||||
- Cloth spine remains visually distinct from leather cover parts.
|
||||
- Head/end-bands read as small woven bookbinding details and participate in mirror, SSAO, custom book shadows, and local bounce lighting.
|
||||
|
||||
### Phase 4: True SSAO Revisit
|
||||
|
||||
Goal: understand, fix, and complete real scene-level SSAO.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Read the Three.js `SSAOPass` behavior and document what data it uses.
|
||||
2. Verify pass order, render target resolution, camera near/far values, and depth/normal availability.
|
||||
3. Create a calibrated `tableDebug=ao` view that makes AO contribution readable.
|
||||
4. Verify that table, book, and candle wax bodies participate.
|
||||
5. Verify that flames and glow sprites do not participate as occluders.
|
||||
6. Tune scene scale/radius/falloff only after the pass is proven active.
|
||||
7. If Three.js `SSAOPass` cannot produce the required effect, replace it with a better GTAO/HBAO-style implementation.
|
||||
8. Prove AO in debug.
|
||||
9. Prove AO in final composite.
|
||||
10. Add regression checks that prevent analytic contact fallback from being reintroduced.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Candle bases show local AO from the real AO pass.
|
||||
- Book/table contact shows local AO from the real AO pass.
|
||||
- The book, table, and candles are equal scene participants.
|
||||
- Scene AO contributes visible crevice/contact depth without becoming broad dirt or painted shadow.
|
||||
- AO does not replace cast shadows.
|
||||
- No fallback contact darkening remains in the final composite.
|
||||
|
||||
### Phase 5: Reflection And Compositing Cleanup
|
||||
|
||||
Goal: make the table reflection physically coherent.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Confirm planar reflection camera alignment.
|
||||
2. Evaluate oblique clip-plane reflection if current mirror math remains fragile.
|
||||
3. Ensure book, candle bodies, wicks, and flames are all reflected.
|
||||
4. Ensure candle body reflection can occlude reflected flame glare.
|
||||
5. Ensure environment reflection is correctly oriented and table-only.
|
||||
6. Tune dust and grease as roughness/specular modifiers only.
|
||||
7. Prove each contribution in debug and final composite.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Reflections align with real object positions.
|
||||
- Candle bodies are visible in reflections.
|
||||
- Flame reflection does not appear as an impossible unoccluded blob.
|
||||
- Table wood remains visible.
|
||||
|
||||
### Phase 6: Integration Back Into App Shell
|
||||
|
||||
Goal: connect the standalone scene back to the real app.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Keep the standalone lab page for visual regression.
|
||||
2. Replace the current UI/html creation module with the WebGL module.
|
||||
3. Keep the old module for reference.
|
||||
4. Add one top menu line over the canvas.
|
||||
5. Add modal overview layer over the canvas.
|
||||
6. Keep compatibility with existing app state, modals, and dynamic text.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Existing app behavior still works.
|
||||
- Canvas scene renders the book UI.
|
||||
- Top menu and modal overview are available.
|
||||
- Dynamic text appears on actual page textures.
|
||||
|
||||
### Phase 7: Procedural Page System
|
||||
|
||||
Goal: implement the future virtual-scrolling book.
|
||||
|
||||
Steps:
|
||||
|
||||
1. Build procedural page stack geometry.
|
||||
2. Add controllable page flip animation.
|
||||
3. Add independent left/right page texture assignment.
|
||||
4. Connect virtual scroll position to page/spread state.
|
||||
5. Backfill recent spreads from virtual content.
|
||||
6. Animate stack thickness changes during navigation.
|
||||
7. Support fast multi-page transitions when jumping through history.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- Page stacks visibly represent progress.
|
||||
- Page flips are controllable and stop at the intended spread.
|
||||
- Both pages show dynamic content.
|
||||
- Virtual scrolling and page animation feel like one system.
|
||||
|
||||
## Current Known Problems
|
||||
|
||||
- The current extreme-progress rendering still needs user screenshot validation after the latest stack-cap material and cap-winding fixes.
|
||||
- The page-content texture must not appear on the spine arc or synthetic support strip.
|
||||
- At `0.00` and very early progress, the side with no real stack must still render with the same topology/material rules as later states.
|
||||
- The current content page surface should stay smooth; construction lines from stack textures must not appear on readable content.
|
||||
- Leather material quality is still provisional and may need further art-direction tuning.
|
||||
- Scene-level SSAO is not accepted as final and is currently not allowed to drive book appearance.
|
||||
- The table reflection path is active and should not be disabled to hide book/cover problems.
|
||||
- Two pathless Windows Node processes may resist termination with access denied; they are not the `:3001` server.
|
||||
|
||||
## Next Immediate Task
|
||||
|
||||
Validate the latest procedural-book integration visually in the running `:3001` lab scene, especially the extreme progress states.
|
||||
|
||||
The next visual work should focus on the book material/topology issues in this order:
|
||||
|
||||
1. Confirm that content appears only on the intended stack-top page surfaces.
|
||||
2. Confirm that spine arc, hinge, cloth, and support strips do not receive page-content texture.
|
||||
3. Confirm that the right stack has a valid top cap even when the left side has no full stack.
|
||||
4. Confirm that readable page surfaces are smooth and not showing construction-line artifacts.
|
||||
5. Continue cover/hinge/spine leather material refinement only after the page-top topology is stable.
|
||||
6. Keep `:3001` as the single current test server.
|
||||
7. Keep OpenGL primitive shadows disabled.
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"pretest-server": "npm run check:node",
|
||||
"test-server": "ts-node src/test-server-yaml.ts",
|
||||
"build": "tsc",
|
||||
"generate:webgl-assets": "python scripts/generate-webgl-table-assets.py",
|
||||
"check:webgl-lab": "node scripts/check-webgl-book-lab.js",
|
||||
"check:webgl-runtime": "node scripts/check-webgl-book-runtime.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint --ext .ts src/",
|
||||
"lint:fix": "eslint --ext .ts src/ --fix"
|
||||
@@ -61,6 +64,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.
|
||||
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 11 MiB |
|
After Width: | Height: | Size: 5.7 MiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 965 KiB |
@@ -1926,3 +1926,323 @@ 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;
|
||||
--ui-menu-font-size: 1rem;
|
||||
--ui-modal-font-size: 1.18rem;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
body.webgl-mode #choices,
|
||||
body.webgl-mode .story-choices {
|
||||
color: rgba(236, 218, 183, 0.9);
|
||||
scrollbar-color: rgba(246, 231, 201, 0.54) rgba(255, 236, 190, 0.08);
|
||||
max-width: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body.webgl-mode #page_left #game_title,
|
||||
body.webgl-mode #page_left #game_author,
|
||||
body.webgl-mode #page_left #game_subtitle,
|
||||
body.webgl-mode #page_left #start_prompt,
|
||||
body.webgl-mode #page_left .separator,
|
||||
body.webgl-mode #page_left .ornament,
|
||||
body.webgl-mode #page_left #game_legal_text,
|
||||
body.webgl-mode #game_title,
|
||||
body.webgl-mode #game_author,
|
||||
body.webgl-mode #game_subtitle,
|
||||
body.webgl-mode #start_prompt {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body.webgl-mode #choices,
|
||||
body.webgl-mode .choices-group,
|
||||
body.webgl-mode .choice-list,
|
||||
body.webgl-mode .choice-list-item {
|
||||
color: rgba(222, 202, 166, 0.86);
|
||||
}
|
||||
|
||||
body.webgl-mode #command_history .history-item {
|
||||
color: rgba(222, 202, 166, 0.76);
|
||||
}
|
||||
|
||||
body.webgl-mode #command_history .history-item:hover,
|
||||
body.webgl-mode #command_history .history-item.active {
|
||||
color: rgba(246, 231, 201, 0.96);
|
||||
}
|
||||
|
||||
body.webgl-mode .story-choices::-webkit-scrollbar-track {
|
||||
background: rgba(255, 236, 190, 0.08);
|
||||
}
|
||||
|
||||
body.webgl-mode .story-choices::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(246, 231, 201, 0.54);
|
||||
}
|
||||
|
||||
body.webgl-mode .choice-list .choice-button {
|
||||
color: rgba(232, 214, 176, 0.88);
|
||||
}
|
||||
|
||||
body.webgl-mode .choices-group > .choice-button {
|
||||
color: rgba(232, 214, 176, 0.88);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
body.webgl-mode .choice-list .choice-button:hover,
|
||||
body.webgl-mode .choice-list .choice-button:focus-visible,
|
||||
body.webgl-mode .choices-group > .choice-button:hover,
|
||||
body.webgl-mode .choices-group > .choice-button:focus-visible {
|
||||
color: rgba(255, 248, 225, 0.98);
|
||||
background: rgba(255, 236, 190, 0.12);
|
||||
outline-color: rgba(255, 236, 190, 0.48);
|
||||
}
|
||||
|
||||
body.webgl-mode .choice-list kbd {
|
||||
color: rgba(246, 231, 201, 0.92);
|
||||
}
|
||||
|
||||
#webgl_app {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: #090705;
|
||||
}
|
||||
|
||||
#webgl_canvas,
|
||||
#scene {
|
||||
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;
|
||||
}
|
||||
|
||||
.control_group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.control_group label,
|
||||
#lab_status {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#top_menu_controls button,
|
||||
.transport_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,
|
||||
.transport_button:hover,
|
||||
.modal-overview-row:hover {
|
||||
background: rgba(87, 55, 31, 0.78);
|
||||
}
|
||||
|
||||
.transport_button {
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.transport_button:disabled {
|
||||
cursor: var(--default-cursor, default);
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
#webgl_book_navigation {
|
||||
position: fixed;
|
||||
z-index: 52;
|
||||
left: 50%;
|
||||
bottom: 18px;
|
||||
transform: translateX(-50%);
|
||||
width: min(820px, calc(100vw - 32px));
|
||||
display: grid;
|
||||
grid-template-columns: 34px 34px minmax(180px, 1fr) 34px 34px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
color: rgba(246, 231, 201, 0.94);
|
||||
background: rgba(12, 9, 7, 0.62);
|
||||
border: 1px solid rgba(246, 231, 201, 0.24);
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.webgl-book-nav-button {
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(246, 231, 201, 0.94);
|
||||
background: rgba(44, 28, 17, 0.74);
|
||||
border: 1px solid rgba(246, 231, 201, 0.26);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webgl-book-nav-button:disabled {
|
||||
opacity: 0.36;
|
||||
cursor: var(--default-cursor, default);
|
||||
}
|
||||
|
||||
.webgl-book-nav-slider-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(72px, auto);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webgl-book-nav-slider-track {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webgl-book-nav-limit-label {
|
||||
min-width: 1.4rem;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: rgba(246, 231, 201, 0.68);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#webgl_book_nav_position {
|
||||
width: 100%;
|
||||
accent-color: #f0cd8e;
|
||||
background:
|
||||
linear-gradient(90deg,
|
||||
rgba(240, 205, 142, 0.48) 0%,
|
||||
rgba(240, 205, 142, 0.48) calc(var(--book-nav-written, 0) * 100%),
|
||||
rgba(246, 231, 201, 0.18) calc(var(--book-nav-written, 0) * 100%),
|
||||
rgba(246, 231, 201, 0.18) calc(var(--book-nav-reserve-start, 1) * 100%),
|
||||
rgba(80, 40, 34, 0.58) calc(var(--book-nav-reserve-start, 1) * 100%),
|
||||
rgba(80, 40, 34, 0.58) 100%);
|
||||
}
|
||||
|
||||
.webgl-book-nav-page-label {
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#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 #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;
|
||||
}
|
||||
|
||||
#lab_status,
|
||||
.control_group label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -280,6 +280,6 @@
|
||||
console.log(message);
|
||||
};
|
||||
</script>
|
||||
<script type="module" src="/js/loader.js?v=20260516-scroll-window"></script>
|
||||
<script type="module" src="/js/loader.js?v=20260608-webgl-mask-timing-c"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Book Page Format Module
|
||||
* Defines the canonical page geometry used by the WebGL book renderer.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
import { calculateProceduralBookThickness, snapProceduralPageCount } from './procedural-book-model.js?v=20260608-webgl-mask-timing-c';
|
||||
|
||||
export const BOOK_TEXTURE_WIDTH = 3072;
|
||||
|
||||
class BookPageFormatModule extends BaseModule {
|
||||
constructor() {
|
||||
super('book-page-format', 'Book Page Format');
|
||||
this.dependencies = [];
|
||||
this.format = Object.freeze({
|
||||
id: 'us-mass-market-paperback',
|
||||
trim: Object.freeze({
|
||||
widthIn: 4.25,
|
||||
heightIn: 6.87
|
||||
}),
|
||||
margins: Object.freeze({
|
||||
topIn: 0.46,
|
||||
bottomIn: 0.58,
|
||||
innerBaseIn: 0.42,
|
||||
innerMinIn: 0.48,
|
||||
innerMaxIn: 0.74,
|
||||
innerThicknessFactor: 0.32,
|
||||
outerBaseIn: 0.27,
|
||||
outerThicknessFactor: 0.015,
|
||||
outerMaxIn: 0.315
|
||||
}),
|
||||
typography: Object.freeze({
|
||||
fontFamily: '"EB Garamond", "EB Garamond 12", serif',
|
||||
linesPerPage: 25,
|
||||
bodyLineRatio: 1.5,
|
||||
headingScale: 1,
|
||||
dropCapLines: 2
|
||||
})
|
||||
});
|
||||
this.pageCount = snapProceduralPageCount(window.WebGLBookInitialState?.pageCount ?? 300);
|
||||
|
||||
this.bindMethods([
|
||||
'getFormat',
|
||||
'getAspectRatio',
|
||||
'getTextureWidth',
|
||||
'getTextureMetrics',
|
||||
'setPageCount',
|
||||
'getPageCount',
|
||||
'getDynamicMargins',
|
||||
'inchesToTexture'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.addEventListener(document, 'webgl-book:page-count-changed', (event) => {
|
||||
this.setPageCount(event.detail?.pageCount);
|
||||
});
|
||||
this.addEventListener(document, 'preference-updated', (event) => {
|
||||
const detail = event.detail || {};
|
||||
if (detail.category === 'webgl' && detail.key === 'bookPageCount') this.setPageCount(detail.value);
|
||||
});
|
||||
this.reportProgress(100, 'Book page format ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
getFormat() {
|
||||
return this.format;
|
||||
}
|
||||
|
||||
getAspectRatio() {
|
||||
return this.format.trim.widthIn / this.format.trim.heightIn;
|
||||
}
|
||||
|
||||
getTextureWidth() {
|
||||
return BOOK_TEXTURE_WIDTH;
|
||||
}
|
||||
|
||||
inchesToTexture(valueIn, textureHeight) {
|
||||
return (Number(valueIn) / this.format.trim.heightIn) * textureHeight;
|
||||
}
|
||||
|
||||
setPageCount(value) {
|
||||
const nextPageCount = snapProceduralPageCount(value ?? this.pageCount);
|
||||
if (nextPageCount === this.pageCount) return this.pageCount;
|
||||
this.pageCount = nextPageCount;
|
||||
return this.pageCount;
|
||||
}
|
||||
|
||||
getPageCount() {
|
||||
return this.pageCount;
|
||||
}
|
||||
|
||||
getDynamicMargins(pageCount = this.pageCount) {
|
||||
const marginConfig = this.format.margins;
|
||||
const thickness = calculateProceduralBookThickness(pageCount);
|
||||
const innerIn = Math.min(
|
||||
marginConfig.innerMaxIn,
|
||||
Math.max(
|
||||
marginConfig.innerMinIn,
|
||||
marginConfig.innerBaseIn + thickness.textBlockThicknessIn * marginConfig.innerThicknessFactor
|
||||
)
|
||||
);
|
||||
const outerIn = Math.min(
|
||||
marginConfig.outerMaxIn,
|
||||
marginConfig.outerBaseIn + thickness.textBlockThicknessIn * marginConfig.outerThicknessFactor
|
||||
);
|
||||
return {
|
||||
topIn: 0.46,
|
||||
bottomIn: 0.58,
|
||||
innerIn,
|
||||
outerIn,
|
||||
thickness
|
||||
};
|
||||
}
|
||||
|
||||
getTextureMetrics(textureWidth = BOOK_TEXTURE_WIDTH, pageCount = this.pageCount) {
|
||||
const width = Math.max(1, Math.round(Number(textureWidth) || 1280));
|
||||
const height = Math.round(width / this.getAspectRatio());
|
||||
const dynamicMargins = this.getDynamicMargins(pageCount);
|
||||
const margins = {
|
||||
top: this.inchesToTexture(dynamicMargins.topIn, height),
|
||||
bottom: this.inchesToTexture(dynamicMargins.bottomIn, height),
|
||||
inner: this.inchesToTexture(dynamicMargins.innerIn, height),
|
||||
outer: this.inchesToTexture(dynamicMargins.outerIn, height)
|
||||
};
|
||||
const content = {
|
||||
x: margins.outer,
|
||||
y: margins.top,
|
||||
width: Math.max(1, width - margins.outer - margins.inner),
|
||||
height: Math.max(1, height - margins.top - margins.bottom)
|
||||
};
|
||||
const contentBySide = {
|
||||
left: {
|
||||
...content,
|
||||
x: margins.outer
|
||||
},
|
||||
right: {
|
||||
...content,
|
||||
x: margins.inner
|
||||
}
|
||||
};
|
||||
const linesPerPage = Math.max(1, Number(this.format.typography.linesPerPage || 25));
|
||||
const typographyLineHeightPx = content.height / linesPerPage;
|
||||
const bodyFontSizePx = typographyLineHeightPx / Math.max(1, Number(this.format.typography.bodyLineRatio || 1.5));
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
aspectRatio: this.getAspectRatio(),
|
||||
margins,
|
||||
content,
|
||||
contentBySide,
|
||||
marginsIn: {
|
||||
top: dynamicMargins.topIn,
|
||||
bottom: dynamicMargins.bottomIn,
|
||||
inner: dynamicMargins.innerIn,
|
||||
outer: dynamicMargins.outerIn
|
||||
},
|
||||
thickness: dynamicMargins.thickness,
|
||||
linesPerPage,
|
||||
bodyFontSizePx,
|
||||
typographyLineHeightPx,
|
||||
typography: this.format.typography
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bookPageFormat = new BookPageFormatModule();
|
||||
|
||||
export { bookPageFormat as BookPageFormat };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(bookPageFormat);
|
||||
}
|
||||
|
||||
window.BookPageFormat = bookPageFormat;
|
||||
@@ -0,0 +1,891 @@
|
||||
/**
|
||||
* Book Playback Timeline Module
|
||||
*
|
||||
* The single owner of WebGL book playback. It sequences the full content
|
||||
* lifecycle for story text:
|
||||
*
|
||||
* prepare (pagination + textures + prewarm)
|
||||
* -> commit (resolve the authoritative target spread)
|
||||
* -> flip (animate a page turn when a spread boundary is crossed)
|
||||
* -> activate (upload the visible textures for the target spread)
|
||||
* -> reveal (animate the new block's text in)
|
||||
*
|
||||
* It drives the scene through the registered `webgl-book-scene` accessor and uses
|
||||
* `webgl-book:*` events only as state notifications. It never touches
|
||||
* `window.BookLabDebug` (debug-only). Cache and scene-preparation misses are
|
||||
* surfaced as problem states instead of being hidden by alternate playback paths.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class BookPlaybackTimelineModule extends BaseModule {
|
||||
constructor() {
|
||||
super('book-playback-timeline', 'Book Playback Timeline');
|
||||
this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator', 'webgl-book-scene'];
|
||||
this.pagination = null;
|
||||
this.textureRenderer = null;
|
||||
this.pageCache = null;
|
||||
this.playbackCoordinator = null;
|
||||
this.scene = null;
|
||||
this.activeSegment = null;
|
||||
this.preparedSegments = new Map();
|
||||
this.maxPreparedSegments = 48;
|
||||
this.paginationGeneration = 0;
|
||||
this.visibleSpreadIndex = 0;
|
||||
this.timelineDiagnostics = [];
|
||||
this.benchmarkEntries = [];
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'playSentence',
|
||||
'prepareSentence',
|
||||
'commitSegmentSpread',
|
||||
'activatePreparedSegment',
|
||||
'ensureAnimationTimings',
|
||||
'calculateAnimationTiming',
|
||||
'createPreparedSegment',
|
||||
'createRevealDetail',
|
||||
'applyTexturePlan',
|
||||
'startRevealForSegment',
|
||||
'assertSegmentReady',
|
||||
'collectRequiredPageMetas',
|
||||
'collectTexturePlanPageMetas',
|
||||
'requiresSpreadTransition',
|
||||
'requiresRightPageFlipAfterReveal',
|
||||
'getBlockRevealSides',
|
||||
'waitForVisualCompletion',
|
||||
'revealContinuationSpread',
|
||||
'waitForPlannedRightReveal',
|
||||
'requestPageFlip',
|
||||
'prepareFlipPlan',
|
||||
'waitForPageFlipFinished',
|
||||
'prewarmSegmentTextures',
|
||||
'getPageMetaForIndex',
|
||||
'getVisibleSpreadIndex',
|
||||
'isChoiceAwaitingPlayer',
|
||||
'invalidatePreparedSegments',
|
||||
'rememberPreparedSegment',
|
||||
'markBenchmark',
|
||||
'timeStage',
|
||||
'recordDiagnostic',
|
||||
'getRuntimeState'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.pagination = this.getModule('book-pagination');
|
||||
this.textureRenderer = this.getModule('book-texture-renderer');
|
||||
this.pageCache = this.getModule('webgl-page-cache');
|
||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||
this.scene = this.getModule('webgl-book-scene');
|
||||
this.visibleSpreadIndex = Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
|
||||
this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => {
|
||||
this.markBenchmark('reveal-start', { blockId: event.detail?.blockId ?? null });
|
||||
});
|
||||
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
||||
this.markBenchmark('reveal-committed', {
|
||||
blockId: event.detail?.blockIds?.[0] ?? null,
|
||||
side: event.detail?.side || null,
|
||||
pageFlipAfterReveal: event.detail?.pageFlipAfterReveal === true
|
||||
});
|
||||
});
|
||||
this.addEventListener(document, 'webgl-book:page-flip-started', (event) => {
|
||||
this.markBenchmark('flip-started', event.detail || {});
|
||||
});
|
||||
this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => {
|
||||
const targetSpread = Number(event.detail?.targetSpread);
|
||||
if (Number.isFinite(targetSpread)) this.visibleSpreadIndex = Math.max(0, Math.round(targetSpread));
|
||||
this.markBenchmark('flip-finished', event.detail || {});
|
||||
});
|
||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.invalidatePreparedSegments);
|
||||
this.addEventListener(document, 'story:history-restoring', this.invalidatePreparedSegments);
|
||||
this.addEventListener(document, 'story:client-reset', () => {
|
||||
this.invalidatePreparedSegments();
|
||||
this.activeSegment = null;
|
||||
});
|
||||
window.BookPlaybackTimeline = this;
|
||||
this.reportProgress(100, 'Book playback timeline ready');
|
||||
return true;
|
||||
}
|
||||
|
||||
async playSentence(sentence = {}) {
|
||||
const segment = await this.timeStage('prepare-current', { blockId: sentence.blockId ?? null }, () => {
|
||||
return this.prepareSentence(sentence, { immediate: true });
|
||||
});
|
||||
if (!segment) {
|
||||
return this.playbackCoordinator?.play?.(sentence);
|
||||
}
|
||||
|
||||
this.activeSegment = segment;
|
||||
document.documentElement.dataset.webglBookPlaybackActive = 'true';
|
||||
this.recordDiagnostic('segment-play:start', segment);
|
||||
|
||||
try {
|
||||
segment.sourceSpreadIndex = this.getVisibleSpreadIndex();
|
||||
// Commit pagination first so the flip targets the authoritative spread,
|
||||
// not the predicted preview spread.
|
||||
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
|
||||
|
||||
if (this.requiresSpreadTransition(segment)) {
|
||||
const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, {
|
||||
reason: 'timeline-preplay-spread-transition',
|
||||
targetSpread: segment.targetSpreadIndex,
|
||||
// The block reveals on these sides right after the flip; the scene must
|
||||
// not flash their full (unmasked) content during the flip's near-end
|
||||
// texture swap — activate will land the masked reveal instead.
|
||||
revealSides: segment.revealSides,
|
||||
force: true
|
||||
}));
|
||||
if (!flipped) {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-preplay-flip-failed',
|
||||
blockId: segment.blockId,
|
||||
targetSpread: segment.targetSpreadIndex
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence));
|
||||
|
||||
sentence.webglRevealController = () => this.startRevealForSegment(segment);
|
||||
const playbackPromise = this.timeStage('playback', segment, () => {
|
||||
return this.playbackCoordinator?.play?.(sentence) || Promise.resolve();
|
||||
});
|
||||
const visualPromise = this.waitForVisualCompletion(segment);
|
||||
await Promise.all([playbackPromise, visualPromise]);
|
||||
} finally {
|
||||
this.recordDiagnostic('segment-play:end', segment);
|
||||
if (this.activeSegment?.key === segment.key) this.activeSegment = null;
|
||||
delete document.documentElement.dataset.webglBookPlaybackActive;
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
async prepareSentence(sentence = {}, options = {}) {
|
||||
if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null;
|
||||
const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`;
|
||||
const cached = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key);
|
||||
const reusable = cached && cached.generation === this.paginationGeneration;
|
||||
if (reusable && options.force !== true) return cached;
|
||||
this.ensureAnimationTimings(sentence);
|
||||
const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', {
|
||||
blockId: sentence.blockId,
|
||||
id: sentence.id
|
||||
}, () => this.createPreparedSegment(sentence, options));
|
||||
if (!segment) return null;
|
||||
this.rememberPreparedSegment(segment);
|
||||
sentence.webglBookPresentation = {
|
||||
...(sentence.webglBookPresentation || {}),
|
||||
prepared: true,
|
||||
blockId: segment.blockId,
|
||||
spread: segment.previewSpread,
|
||||
timelineSegment: segment
|
||||
};
|
||||
this.recordDiagnostic('segment-prepare:end', segment);
|
||||
return segment;
|
||||
}
|
||||
|
||||
rememberPreparedSegment(segment = {}) {
|
||||
if (!segment?.key) return;
|
||||
this.preparedSegments.delete(segment.key);
|
||||
this.preparedSegments.set(segment.key, segment);
|
||||
while (this.preparedSegments.size > this.maxPreparedSegments) {
|
||||
const oldestKey = this.preparedSegments.keys().next().value;
|
||||
this.preparedSegments.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
invalidatePreparedSegments() {
|
||||
this.paginationGeneration += 1;
|
||||
this.preparedSegments.clear();
|
||||
}
|
||||
|
||||
async createPreparedSegment(sentence = {}, options = {}) {
|
||||
const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, {
|
||||
activate: false,
|
||||
publish: false,
|
||||
includeUnrenderedHistory: true
|
||||
});
|
||||
if (!previewSpread) return null;
|
||||
|
||||
// Every block is prepared once, spanning-aware. The preview layout (attached to the
|
||||
// preview spread by pagination) tells us whether the block overflows onto the next
|
||||
// spread; if so we derive the start spread's timing across both spreads and prepare the
|
||||
// continuation spread now. activate and revealContinuationSpread then reuse these — one
|
||||
// prepare path, no synchronous rebuild or redraw on the critical path.
|
||||
const previewSpreads = Array.isArray(previewSpread.previewSpreads) ? previewSpread.previewSpreads : null;
|
||||
const startIndex = Math.max(0, Number(previewSpread.index || 0));
|
||||
const continuationSpread = previewSpreads
|
||||
? (previewSpreads
|
||||
.filter(spread => spread
|
||||
&& Number(spread.index) > startIndex
|
||||
&& this.getBlockRevealSides(spread, sentence.blockId).length > 0)
|
||||
.sort((a, b) => Number(a.index) - Number(b.index))[0] || null)
|
||||
: null;
|
||||
|
||||
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
||||
const texturePlan = await this.textureRenderer.prepareRevealBlock(
|
||||
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
|
||||
{ phase: 'prepare', publishEvent: false }
|
||||
);
|
||||
if (continuationSpread) {
|
||||
await this.textureRenderer.prepareContinuationRevealPlan({
|
||||
...revealDetail,
|
||||
previewSpreads,
|
||||
continuationSpread
|
||||
});
|
||||
}
|
||||
|
||||
const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0));
|
||||
const currentSpreadIndex = this.getVisibleSpreadIndex();
|
||||
const revealSides = this.getBlockRevealSides(previewSpread, sentence.blockId);
|
||||
const segment = {
|
||||
key: `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`,
|
||||
id: sentence.id,
|
||||
blockId: sentence.blockId,
|
||||
sentence,
|
||||
generation: this.paginationGeneration,
|
||||
previewSpread,
|
||||
targetSpreadIndex,
|
||||
currentSpreadIndex,
|
||||
revealSides,
|
||||
requiresPreFlip: targetSpreadIndex > currentSpreadIndex,
|
||||
requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread),
|
||||
// Snapshot the reveal timings now. A reused lookahead segment can be played by
|
||||
// a sentence instance whose animation timings were lost; without them the
|
||||
// reveal can't be word-paced and stretches across the whole TTS.
|
||||
preparedAnimation: {
|
||||
wordTimings: Array.isArray(revealDetail.wordTimings) ? revealDetail.wordTimings : [],
|
||||
cueTimings: Array.isArray(revealDetail.cueTimings) ? revealDetail.cueTimings : [],
|
||||
totalDuration: Number(revealDetail.totalDuration || 0)
|
||||
},
|
||||
preparedTexturePlan: texturePlan,
|
||||
preparedAt: performance.now(),
|
||||
revealStartedAt: null,
|
||||
revealStartedPromise: null,
|
||||
resolveRevealStarted: null,
|
||||
status: 'prepared'
|
||||
};
|
||||
segment.revealStartedPromise = new Promise(resolve => {
|
||||
segment.resolveRevealStarted = resolve;
|
||||
});
|
||||
|
||||
this.applyTexturePlan(texturePlan, segment, 'prepare');
|
||||
await this.timeStage('texture-prewarm', segment, () => this.prewarmSegmentTextures(segment));
|
||||
await this.assertSegmentReady(segment, 'prepare');
|
||||
if (options.immediate !== true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
|
||||
if (!segment || !sentence) return null;
|
||||
segment.sourceSpreadIndex = Number.isFinite(Number(segment.sourceSpreadIndex))
|
||||
? Math.max(0, Math.round(Number(segment.sourceSpreadIndex)))
|
||||
: this.getVisibleSpreadIndex();
|
||||
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
|
||||
includeUnrenderedHistory: true
|
||||
});
|
||||
segment.activeSpread = activeSpread || segment.previewSpread;
|
||||
segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0));
|
||||
segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId);
|
||||
// Does the block overflow onto the next spread? The committed pagination now knows
|
||||
// this (during lookahead it was not yet committed), so detect it here.
|
||||
const nextSpread = typeof this.pagination?.getSpread === 'function'
|
||||
? this.pagination.getSpread(segment.targetSpreadIndex + 1)
|
||||
: this.pagination?.spreads?.[segment.targetSpreadIndex + 1];
|
||||
segment.spansToNextSpread = Boolean(nextSpread)
|
||||
&& this.getBlockRevealSides(nextSpread, sentence.blockId).length > 0;
|
||||
// A spanning block, or one that fills the right page, must flip to keep revealing
|
||||
// its continuation rather than leaving the right page's last line to absorb the
|
||||
// whole TTS while the rest pops in complete after the flip.
|
||||
segment.requiresRightFlip = segment.revealSides.includes('right')
|
||||
&& (segment.spansToNextSpread || this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread));
|
||||
this.recordDiagnostic('segment-commit:end', segment);
|
||||
return segment.activeSpread;
|
||||
}
|
||||
|
||||
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
|
||||
if (!segment || !sentence) return null;
|
||||
// Restore the reveal timings captured at prepare if the live sentence lost them,
|
||||
// otherwise the reveal degrades to an area estimate spanning the whole TTS.
|
||||
if (segment.preparedAnimation?.wordTimings?.length && !(sentence.animation?.wordTimings?.length)) {
|
||||
sentence.animation = {
|
||||
...(sentence.animation || {}),
|
||||
wordTimings: segment.preparedAnimation.wordTimings,
|
||||
cueTimings: segment.preparedAnimation.cueTimings,
|
||||
totalDuration: segment.preparedAnimation.totalDuration
|
||||
};
|
||||
}
|
||||
const spread = segment.activeSpread || segment.previewSpread;
|
||||
let texturePlan = segment.preparedTexturePlan
|
||||
? { ...segment.preparedTexturePlan, phase: 'activate' }
|
||||
: null;
|
||||
if (texturePlan && this.pageCache?.hasPreparedRevealPlan?.(segment.blockId)) {
|
||||
this.pageCache.takePreparedRevealPlan(segment.blockId);
|
||||
}
|
||||
if (!texturePlan) {
|
||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||
texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||
}
|
||||
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
|
||||
// both pages. No synchronous redraw on the critical path.
|
||||
segment.activeTexturePlan = texturePlan;
|
||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||
await this.assertSegmentReady(segment, 'activate');
|
||||
segment.status = 'activated';
|
||||
this.recordDiagnostic('segment-activate:end', segment);
|
||||
return spread;
|
||||
}
|
||||
|
||||
ensureAnimationTimings(sentence = {}) {
|
||||
const existingTimings = Array.isArray(sentence.animation?.wordTimings)
|
||||
? sentence.animation.wordTimings
|
||||
: [];
|
||||
const existingDuration = existingTimings.reduce((max, timing) => Math.max(
|
||||
max,
|
||||
Number(timing?.delay || 0) + Number(timing?.duration || 0)
|
||||
), Number(sentence.animation?.totalDuration || 0));
|
||||
const ttsDuration = Number(sentence.tts?.duration || 0);
|
||||
if (existingTimings.length > 0 && (existingDuration > 0 || ttsDuration <= 0)) return;
|
||||
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
|
||||
sentence.animation = this.calculateAnimationTiming(words, ttsDuration, sentence.cueMarkers || []);
|
||||
}
|
||||
|
||||
calculateAnimationTiming(words = [], totalDuration = 0, cueMarkers = []) {
|
||||
if (!Array.isArray(words) || words.length === 0) {
|
||||
return {
|
||||
wordTimings: [],
|
||||
cueTimings: [],
|
||||
totalDuration: 0
|
||||
};
|
||||
}
|
||||
const totalChars = words.reduce((sum, word) => sum + String(word || '').length, 0);
|
||||
if (totalChars === 0) {
|
||||
return {
|
||||
wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })),
|
||||
cueTimings: [],
|
||||
totalDuration: 0
|
||||
};
|
||||
}
|
||||
const msPerChar = Number(totalDuration || 0) / totalChars;
|
||||
let currentDelay = 0;
|
||||
const wordTimings = words.map(word => {
|
||||
const duration = String(word || '').length * msPerChar;
|
||||
const timing = {
|
||||
word,
|
||||
delay: currentDelay,
|
||||
duration
|
||||
};
|
||||
currentDelay += duration;
|
||||
return timing;
|
||||
});
|
||||
const cueTimings = (cueMarkers || []).map(cue => {
|
||||
const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1));
|
||||
const timing = wordTimings[wordIndex] || { delay: currentDelay };
|
||||
return {
|
||||
...cue,
|
||||
delay: timing.delay
|
||||
};
|
||||
});
|
||||
return {
|
||||
wordTimings,
|
||||
cueTimings,
|
||||
totalDuration: Math.round(currentDelay)
|
||||
};
|
||||
}
|
||||
|
||||
createRevealDetail(sentence = {}, spread = null, phase = 'activate') {
|
||||
return {
|
||||
id: sentence.id,
|
||||
blockId: sentence.blockId,
|
||||
wordTimings: sentence.animation?.wordTimings || [],
|
||||
cueTimings: sentence.animation?.cueTimings || [],
|
||||
totalDuration: sentence.animation?.totalDuration || 0,
|
||||
spread,
|
||||
phase
|
||||
};
|
||||
}
|
||||
|
||||
applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') {
|
||||
if (!texturePlan) {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-missing-texture-plan',
|
||||
blockId: segment.blockId ?? null,
|
||||
phase
|
||||
});
|
||||
return false;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
|
||||
detail: {
|
||||
...texturePlan,
|
||||
phase: phase === 'prepare' ? 'prepare' : 'activate'
|
||||
}
|
||||
}));
|
||||
this.recordDiagnostic(`texture-plan-applied:${phase}`, segment);
|
||||
return true;
|
||||
}
|
||||
|
||||
startRevealForSegment(segment = {}) {
|
||||
if (!segment?.blockId) return false;
|
||||
// Mark the renderer animation as started, then let the scene render loop —
|
||||
// the single reveal clock — drive timing via the dispatched reveal-start event.
|
||||
const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, {
|
||||
publishEvent: true
|
||||
});
|
||||
if (!revealStart) {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-prepared-reveal-missing',
|
||||
blockId: segment.blockId
|
||||
});
|
||||
return false;
|
||||
}
|
||||
segment.revealStartedAt = performance.now();
|
||||
if (typeof segment.resolveRevealStarted === 'function') {
|
||||
segment.resolveRevealStarted(segment.revealStartedAt);
|
||||
segment.resolveRevealStarted = null;
|
||||
}
|
||||
this.markBenchmark('reveal-start', segment);
|
||||
this.recordDiagnostic('reveal-started', segment);
|
||||
return true;
|
||||
}
|
||||
|
||||
requiresSpreadTransition(segment = {}) {
|
||||
const sourceSpread = Number.isFinite(Number(segment.sourceSpreadIndex))
|
||||
? Math.max(0, Math.round(Number(segment.sourceSpreadIndex)))
|
||||
: this.getVisibleSpreadIndex();
|
||||
return Math.max(0, Number(segment.targetSpreadIndex || 0)) > sourceSpread;
|
||||
}
|
||||
|
||||
requiresRightPageFlipAfterReveal(spread = {}) {
|
||||
const meta = spread?.pageMeta?.right || null;
|
||||
if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false;
|
||||
const rightLines = Array.isArray(spread?.right) ? spread.right : [];
|
||||
const maxLine = rightLines.reduce((max, line) => Math.max(
|
||||
max,
|
||||
Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1))
|
||||
), 0);
|
||||
return maxLine >= Math.max(1, Number(meta.linesPerPage || 25));
|
||||
}
|
||||
|
||||
getBlockRevealSides(spread = {}, blockId = null) {
|
||||
const id = String(blockId ?? '');
|
||||
if (!id) return [];
|
||||
return ['left', 'right'].filter((side) => {
|
||||
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
|
||||
return lines.some(line => String(line?.blockId ?? '') === id);
|
||||
});
|
||||
}
|
||||
|
||||
async waitForVisualCompletion(segment = {}) {
|
||||
if (!segment.requiresRightFlip || !Array.isArray(segment.revealSides) || !segment.revealSides.includes('right')) {
|
||||
this.recordDiagnostic('visual-completion:no-right-flip-wait', segment);
|
||||
return;
|
||||
}
|
||||
const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment));
|
||||
if (!committed || this.isChoiceAwaitingPlayer()) return;
|
||||
const continuationSpreadIndex = Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1);
|
||||
const continuationSpread = typeof this.pagination?.getSpread === 'function'
|
||||
? this.pagination.getSpread(continuationSpreadIndex)
|
||||
: this.pagination?.spreads?.[continuationSpreadIndex];
|
||||
// If the block continues onto the next spread, that page must keep revealing the
|
||||
// carried-over lines after the flip instead of appearing already complete.
|
||||
const continuationSides = continuationSpread ? this.getBlockRevealSides(continuationSpread, segment.blockId) : [];
|
||||
const flipped = await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, {
|
||||
reason: 'timeline-right-page-filled',
|
||||
targetSpread: continuationSpreadIndex,
|
||||
revealSides: continuationSides,
|
||||
force: true
|
||||
}));
|
||||
if (flipped && continuationSides.length > 0) {
|
||||
await this.timeStage('reveal-continuation', segment, () => this.revealContinuationSpread(segment, continuationSpread));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-apply the active block's reveal on the spread it continues onto. The renderer
|
||||
// already produces reveal regions for that spread with global (continuous) timing;
|
||||
// the scene resumes the same reveal clock (the block's original start persists), so
|
||||
// the carried-over lines animate in instead of popping in fully revealed.
|
||||
async revealContinuationSpread(segment = {}, spread = null) {
|
||||
const sentence = segment.sentence;
|
||||
if (!sentence || !spread) return false;
|
||||
// Reuse the continuation plan prepared during lookahead. It is always prepared when a
|
||||
// block spans (createPreparedSegment), so a miss is a real bug, not a redraw cue.
|
||||
const texturePlan = this.textureRenderer.takeContinuationRevealPlan(segment.blockId, spread.index);
|
||||
if (!texturePlan) {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-reveal-continuation-missing',
|
||||
blockId: segment.blockId,
|
||||
spreadIndex: Number(spread.index ?? null)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
segment.activeTexturePlan = texturePlan;
|
||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||
await this.assertSegmentReady(segment, 'activate');
|
||||
this.recordDiagnostic('reveal-continuation:applied', segment);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve when the right page's own portion of the reveal is done — its computed
|
||||
// duration elapses, the reveal commits, or the player fast-forwards — whichever comes
|
||||
// first. Single timer + listeners with full cleanup, so no stray commit-timeout fires.
|
||||
async waitForPlannedRightReveal(segment = {}) {
|
||||
const startedAt = Number(segment.revealStartedAt)
|
||||
|| await (segment.revealStartedPromise || Promise.resolve(performance.now()));
|
||||
const duration = this.getRightRevealDurationMs(segment);
|
||||
segment.plannedRightRevealDurationMs = duration;
|
||||
this.recordDiagnostic('wait-right-reveal-planned', {
|
||||
...segment,
|
||||
plannedRightRevealDurationMs: duration
|
||||
});
|
||||
const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now()));
|
||||
const remaining = Math.max(0, duration - elapsed);
|
||||
const blockId = String(segment.blockId ?? '');
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('webgl-book:reveal-committed', onCommit);
|
||||
document.removeEventListener('webgl-book:page-reveal-fast-forward', onFastForward);
|
||||
resolve(value);
|
||||
};
|
||||
const onCommit = (event) => {
|
||||
const detail = event.detail || {};
|
||||
if (detail.side !== 'right') return;
|
||||
const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : [];
|
||||
if (blockId && ids.length && !ids.includes(blockId)) return;
|
||||
finish(true);
|
||||
};
|
||||
const onFastForward = () => finish(true);
|
||||
const timer = setTimeout(() => finish(true), remaining);
|
||||
document.addEventListener('webgl-book:reveal-committed', onCommit);
|
||||
document.addEventListener('webgl-book:page-reveal-fast-forward', onFastForward);
|
||||
});
|
||||
}
|
||||
|
||||
getRightRevealDurationMs(segment = {}) {
|
||||
const duration = Number(segment.activeTexturePlan?.reveal?.right?.durationMs
|
||||
?? segment.preparedTexturePlan?.reveal?.right?.durationMs
|
||||
?? 0);
|
||||
if (Number.isFinite(duration) && duration > 0) return duration;
|
||||
return Math.max(1, Number(segment.sentence?.animation?.totalDuration || 1));
|
||||
}
|
||||
|
||||
async requestPageFlip(direction = 1, options = {}) {
|
||||
if (this.isChoiceAwaitingPlayer()) return false;
|
||||
const flipPlan = await this.prepareFlipPlan(direction, options);
|
||||
await this.assertSegmentReady({
|
||||
blockId: options.blockId ?? null,
|
||||
targetSpreadIndex: options.targetSpread,
|
||||
revealSides: []
|
||||
}, 'flip');
|
||||
const sceneControl = this.scene?.sceneControl || null;
|
||||
if (typeof sceneControl?.prewarmPageFlip !== 'function' || typeof sceneControl?.startPreparedPageFlip !== 'function') {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-scene-flip-api-missing',
|
||||
targetSpread: flipPlan.targetSpread,
|
||||
reason: options.reason || 'timeline'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const scenePrewarm = await sceneControl.prewarmPageFlip(direction, {
|
||||
targetSpread: flipPlan.targetSpread,
|
||||
reason: options.reason || 'timeline'
|
||||
});
|
||||
const started = sceneControl.startPreparedPageFlip(direction, {
|
||||
force: options.force === true,
|
||||
reason: options.reason || 'timeline',
|
||||
targetSpread: flipPlan.targetSpread,
|
||||
deferRevealSides: Array.isArray(options.revealSides) ? options.revealSides : null,
|
||||
flipPlan,
|
||||
prewarm: scenePrewarm
|
||||
});
|
||||
if (!started) {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-scene-flip-start-failed',
|
||||
targetSpread: flipPlan.targetSpread,
|
||||
reason: options.reason || 'timeline'
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return this.waitForPageFlipFinished(flipPlan.targetSpread, { alreadyStarted: true });
|
||||
}
|
||||
|
||||
async prepareFlipPlan(direction = 1, options = {}) {
|
||||
const currentSpread = this.getVisibleSpreadIndex();
|
||||
const targetSpread = Number.isFinite(Number(options.targetSpread))
|
||||
? Math.max(0, Math.round(Number(options.targetSpread)))
|
||||
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
|
||||
const prewarm = await this.pageCache?.prewarmNavigationWindow?.({
|
||||
currentSpread,
|
||||
targetSpread,
|
||||
endSpread: Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1),
|
||||
getPageMetaForIndex: this.getPageMetaForIndex,
|
||||
recordMiss: false
|
||||
});
|
||||
const sourceSide = direction > 0 ? 'right' : 'left';
|
||||
const backSide = direction > 0 ? 'left' : 'right';
|
||||
const sourcePageIndex = currentSpread * 2 + (sourceSide === 'right' ? 1 : 0);
|
||||
const backPageIndex = targetSpread * 2 + (backSide === 'right' ? 1 : 0);
|
||||
const plan = {
|
||||
direction,
|
||||
currentSpread,
|
||||
targetSpread,
|
||||
sourceSide,
|
||||
backSide,
|
||||
sourcePageMeta: this.getPageMetaForIndex(sourcePageIndex),
|
||||
backPageMeta: this.getPageMetaForIndex(backPageIndex),
|
||||
prewarm,
|
||||
createdAt: performance.now()
|
||||
};
|
||||
this.markBenchmark('flip-plan-ready', plan);
|
||||
this.recordDiagnostic('flip-plan-ready', {
|
||||
...plan,
|
||||
targetSpreadIndex: targetSpread
|
||||
});
|
||||
return plan;
|
||||
}
|
||||
|
||||
async prewarmSegmentTextures(segment = {}) {
|
||||
if (!this.pageCache || typeof this.pageCache.prewarmNavigationWindow !== 'function') return null;
|
||||
const targetSpread = Math.max(0, Number(segment.targetSpreadIndex || 0));
|
||||
const endSpread = Math.max(targetSpread, Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1));
|
||||
const result = await this.pageCache.prewarmNavigationWindow({
|
||||
currentSpread: this.getVisibleSpreadIndex(),
|
||||
targetSpread,
|
||||
endSpread,
|
||||
getPageMetaForIndex: this.getPageMetaForIndex,
|
||||
recordMiss: false
|
||||
});
|
||||
segment.textureWindowReady = true;
|
||||
segment.textureWindowSpreadCount = result ? Object.keys(result).length : 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
collectRequiredPageMetas(segment = {}, phase = 'play') {
|
||||
if (phase === 'prepare') {
|
||||
return this.collectTexturePlanPageMetas(segment.preparedTexturePlan);
|
||||
}
|
||||
if (phase === 'activate' || phase === 'play') {
|
||||
return this.collectTexturePlanPageMetas(segment.activeTexturePlan || segment.preparedTexturePlan);
|
||||
}
|
||||
const currentSpread = this.getVisibleSpreadIndex();
|
||||
const targetSpread = Number.isFinite(Number(segment.targetSpreadIndex))
|
||||
? Math.max(0, Math.round(Number(segment.targetSpreadIndex)))
|
||||
: currentSpread;
|
||||
return Array.from(new Set([currentSpread, targetSpread]))
|
||||
.flatMap(spread => [
|
||||
this.getPageMetaForIndex(spread * 2),
|
||||
this.getPageMetaForIndex(spread * 2 + 1)
|
||||
]);
|
||||
}
|
||||
|
||||
collectTexturePlanPageMetas(texturePlan = null) {
|
||||
const pageMeta = texturePlan?.pageMeta || {};
|
||||
const records = Array.isArray(texturePlan?.records) ? texturePlan.records : [];
|
||||
const metas = records
|
||||
.map(record => record?.pageMeta || pageMeta?.[record?.side])
|
||||
.filter(meta => meta && Number.isFinite(Number(meta.pageIndex)));
|
||||
['left', 'right'].forEach((side) => {
|
||||
const meta = pageMeta?.[side];
|
||||
if (!meta || !Number.isFinite(Number(meta.pageIndex))) return;
|
||||
if (metas.some(existing => Number(existing.pageIndex) === Number(meta.pageIndex))) return;
|
||||
metas.push(meta);
|
||||
});
|
||||
return metas;
|
||||
}
|
||||
|
||||
async assertSegmentReady(segment = {}, phase = 'play') {
|
||||
if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') {
|
||||
this.recordDiagnostic(`cache-unavailable:${phase}`, segment);
|
||||
return false;
|
||||
}
|
||||
const metas = this.collectRequiredPageMetas(segment, phase);
|
||||
const missing = [];
|
||||
await Promise.all(metas.map(async (meta) => {
|
||||
const texture = await this.pageCache.ensurePageTexture(meta, {
|
||||
recordMiss: true
|
||||
});
|
||||
if (!texture) missing.push(meta);
|
||||
}));
|
||||
if (missing.length > 0) {
|
||||
// Surface the problem but do not throw out of the live playback path.
|
||||
this.pageCache.recordProblem?.({
|
||||
type: 'timeline-cache-readiness-failed',
|
||||
phase,
|
||||
blockId: segment.blockId ?? null,
|
||||
missingPages: missing.map(meta => meta.pageIndex ?? null)
|
||||
});
|
||||
segment.cacheReady = false;
|
||||
segment.cacheReadyPhase = phase;
|
||||
return false;
|
||||
}
|
||||
segment.cacheReady = true;
|
||||
segment.cacheReadyPhase = phase;
|
||||
this.recordDiagnostic(`cache-ready:${phase}`, segment);
|
||||
return true;
|
||||
}
|
||||
|
||||
getPageMetaForIndex(pageIndex = 0) {
|
||||
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
|
||||
const spreadIndex = Math.floor(index / 2);
|
||||
const side = index % 2 === 0 ? 'left' : 'right';
|
||||
const spread = typeof this.pagination?.getSpread === 'function'
|
||||
? this.pagination.getSpread(spreadIndex)
|
||||
: this.pagination?.spreads?.[spreadIndex];
|
||||
const metrics = this.textureRenderer?.metrics || {};
|
||||
if (!spread) {
|
||||
return {
|
||||
pageIndex: index,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
kind: 'blank',
|
||||
section: index < 3 ? 'frontmatter' : 'body',
|
||||
pageNumber: null,
|
||||
omitPageNumber: true
|
||||
};
|
||||
}
|
||||
const source = spread?.pageMeta?.[side] || {};
|
||||
return {
|
||||
...source,
|
||||
pageIndex: index,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
kind: source.kind || (index < 3 ? 'blank' : 'content'),
|
||||
section: source.section || (index < 3 ? 'frontmatter' : 'body')
|
||||
};
|
||||
}
|
||||
|
||||
waitForPageFlipFinished(targetSpread = null, options = {}) {
|
||||
return new Promise(resolve => {
|
||||
let started = options.alreadyStarted === true;
|
||||
let resolved = false;
|
||||
const expectedSpread = Number.isFinite(Number(targetSpread))
|
||||
? Math.max(0, Math.round(Number(targetSpread)))
|
||||
: null;
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('webgl-book:page-flip-started', onStarted);
|
||||
document.removeEventListener('webgl-book:page-flip-finished', onFinished);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const matches = (detail = {}) => {
|
||||
if (expectedSpread === null) return true;
|
||||
const spread = Number(detail.targetSpread);
|
||||
return Number.isFinite(spread) && Math.max(0, Math.round(spread)) === expectedSpread;
|
||||
};
|
||||
const onStarted = (event) => {
|
||||
if (matches(event.detail || {})) started = true;
|
||||
};
|
||||
const onFinished = (event) => {
|
||||
if (matches(event.detail || {})) finish(true);
|
||||
};
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-page-flip-timeout',
|
||||
targetSpread: expectedSpread,
|
||||
started
|
||||
});
|
||||
finish(false);
|
||||
}, 2600);
|
||||
document.addEventListener('webgl-book:page-flip-started', onStarted);
|
||||
document.addEventListener('webgl-book:page-flip-finished', onFinished);
|
||||
});
|
||||
}
|
||||
|
||||
getVisibleSpreadIndex() {
|
||||
const sceneSpread = this.scene?.getVisibleSpreadIndex?.();
|
||||
if (Number.isFinite(Number(sceneSpread))) return Math.max(0, Math.round(Number(sceneSpread)));
|
||||
if (Number.isFinite(Number(this.visibleSpreadIndex))) return Math.max(0, Math.round(Number(this.visibleSpreadIndex)));
|
||||
return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
|
||||
}
|
||||
|
||||
isChoiceAwaitingPlayer() {
|
||||
return document.documentElement.dataset.choiceAwaiting === 'true'
|
||||
|| document.body?.dataset?.choiceAwaiting === 'true'
|
||||
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
||||
}
|
||||
|
||||
recordDiagnostic(type, segment = {}) {
|
||||
this.timelineDiagnostics.push({
|
||||
type,
|
||||
blockId: segment.blockId ?? null,
|
||||
spreadIndex: segment.targetSpreadIndex ?? null,
|
||||
status: segment.status || null,
|
||||
revealSides: Array.isArray(segment.revealSides) ? segment.revealSides : [],
|
||||
plannedRightRevealDurationMs: Number.isFinite(Number(segment.plannedRightRevealDurationMs))
|
||||
? Math.round(Number(segment.plannedRightRevealDurationMs))
|
||||
: undefined,
|
||||
at: Math.round(performance.now())
|
||||
});
|
||||
while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift();
|
||||
document.documentElement.dataset.webglBookTimeline = type;
|
||||
}
|
||||
|
||||
markBenchmark(stage, detail = {}, startedAt = null) {
|
||||
const now = performance.now();
|
||||
const entry = {
|
||||
stage,
|
||||
blockId: detail.blockId ?? null,
|
||||
spreadIndex: detail.targetSpreadIndex ?? detail.spreadIndex ?? detail.targetSpread ?? null,
|
||||
durationMs: Number.isFinite(Number(startedAt)) ? Math.round((now - Number(startedAt)) * 100) / 100 : null,
|
||||
at: Math.round(now),
|
||||
detail: {
|
||||
status: detail.status || null,
|
||||
revealSides: Array.isArray(detail.revealSides) ? detail.revealSides : undefined,
|
||||
reason: detail.reason || null,
|
||||
side: detail.side || null,
|
||||
pageFlipAfterReveal: detail.pageFlipAfterReveal === true
|
||||
}
|
||||
};
|
||||
this.benchmarkEntries.push(entry);
|
||||
while (this.benchmarkEntries.length > 240) this.benchmarkEntries.shift();
|
||||
document.documentElement.dataset.webglBookBenchmark = JSON.stringify(this.benchmarkEntries.slice(-40));
|
||||
return entry;
|
||||
}
|
||||
|
||||
async timeStage(stage, detail = {}, callback = null) {
|
||||
const startedAt = performance.now();
|
||||
this.markBenchmark(`${stage}:start`, detail);
|
||||
try {
|
||||
const result = await callback?.();
|
||||
this.markBenchmark(`${stage}:end`, detail, startedAt);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.markBenchmark(`${stage}:error`, {
|
||||
...detail,
|
||||
reason: error?.message || String(error)
|
||||
}, startedAt);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getRuntimeState() {
|
||||
return {
|
||||
activeBlockId: this.activeSegment?.blockId ?? null,
|
||||
preparedSegmentCount: this.preparedSegments.size,
|
||||
paginationGeneration: this.paginationGeneration,
|
||||
visibleSpreadIndex: this.visibleSpreadIndex,
|
||||
diagnostics: this.timelineDiagnostics.slice(-20),
|
||||
benchmark: this.benchmarkEntries.slice(-40)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bookPlaybackTimeline = new BookPlaybackTimelineModule();
|
||||
|
||||
export { bookPlaybackTimeline as BookPlaybackTimeline };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(bookPlaybackTimeline);
|
||||
}
|
||||
|
||||
window.BookPlaybackTimeline = bookPlaybackTimeline;
|
||||
@@ -0,0 +1,370 @@
|
||||
// OffscreenCanvas page rasterizer. Runs off the main thread so the heavy page text drawing
|
||||
// (the bulk of drawSpread cost) never blocks the render loop or UI. The main thread sends a
|
||||
// draw job (line records + metrics + page meta + title data + preloaded image bitmaps) and
|
||||
// receives back a full-page ImageBitmap and a background-only base ImageBitmap per side; the
|
||||
// main thread blits those onto its existing page canvases, leaving the texture/reveal pipeline
|
||||
// unchanged. This is the single rasterization implementation — the main thread no longer draws
|
||||
// page text itself.
|
||||
|
||||
let fontsReady = null;
|
||||
const imageCache = new Map(); // src -> ImageBitmap | null
|
||||
const surfaces = {}; // side -> { canvas, ctx }
|
||||
// The reveal "base" layer is the plain paper background (drawPageBase) — identical for every
|
||||
// page of a side at a given size. Send its bitmap only once per side+size; the main thread
|
||||
// caches and reuses it, avoiding a large per-block ImageBitmap allocation (GC churn).
|
||||
const sentBaseKeys = new Set();
|
||||
|
||||
function resolveImageSource(metadata = {}) {
|
||||
const explicit = String(metadata.url || metadata.src || '').trim();
|
||||
if (explicit) return explicit;
|
||||
const filename = String(metadata.filename || '').trim();
|
||||
if (!filename) return '';
|
||||
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
|
||||
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
|
||||
}
|
||||
|
||||
async function ensureImages(srcs = []) {
|
||||
await Promise.all(srcs.map(async (src) => {
|
||||
if (!src || imageCache.has(src)) return;
|
||||
try {
|
||||
const response = await fetch(src);
|
||||
const blob = await response.blob();
|
||||
imageCache.set(src, await createImageBitmap(blob));
|
||||
} catch (error) {
|
||||
imageCache.set(src, null);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureFonts() {
|
||||
if (fontsReady) return fontsReady;
|
||||
if (typeof FontFace === 'undefined' || !self.fonts) {
|
||||
fontsReady = Promise.resolve();
|
||||
return fontsReady;
|
||||
}
|
||||
const faces = [
|
||||
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Regular.otf)', { style: 'normal', weight: '400' }),
|
||||
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Italic.otf)', { style: 'italic', weight: '400' }),
|
||||
new FontFace('EB Garamond 12', 'url(/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2)', {}),
|
||||
new FontFace('EB Garamond Initials', 'url(/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf)', {})
|
||||
];
|
||||
fontsReady = Promise.all(faces.map(face => face.load()
|
||||
.then(loaded => { self.fonts.add(loaded); })
|
||||
.catch(() => {})));
|
||||
return fontsReady;
|
||||
}
|
||||
|
||||
function getSurface(width, height) {
|
||||
if (!surfaces.shared) {
|
||||
surfaces.shared = { canvas: new OffscreenCanvas(width, height) };
|
||||
surfaces.shared.ctx = surfaces.shared.canvas.getContext('2d');
|
||||
}
|
||||
const surface = surfaces.shared;
|
||||
if (surface.canvas.width !== width) surface.canvas.width = width;
|
||||
if (surface.canvas.height !== height) surface.canvas.height = height;
|
||||
return surface;
|
||||
}
|
||||
|
||||
function getPageContent(metrics, side) {
|
||||
return metrics?.contentBySide?.[side] || metrics?.content || {
|
||||
x: 0, y: 0, width: metrics?.width || 1, height: metrics?.height || 1
|
||||
};
|
||||
}
|
||||
|
||||
function getInlineStyleState(tags = [], base = {}) {
|
||||
const state = { bold: Boolean(base.bold), italic: Boolean(base.italic) };
|
||||
tags.forEach(tag => {
|
||||
if (tag?.bold) state.bold = true;
|
||||
if (tag?.italic) state.italic = true;
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
// DOM-free inline-tag parser (the main-thread renderer used document.createElement; a worker
|
||||
// has no DOM, so parse the tag string directly).
|
||||
function updateInlineStyleState(stack = [], value = '') {
|
||||
const text = String(value || '');
|
||||
if (!text.startsWith('<')) return stack;
|
||||
if (text.startsWith('</')) {
|
||||
if (stack.length) stack.pop();
|
||||
return stack;
|
||||
}
|
||||
const tagMatch = text.match(/^<\s*([a-zA-Z0-9]+)/);
|
||||
if (!tagMatch) return stack;
|
||||
const tagName = tagMatch[1].toLowerCase();
|
||||
const style = (text.match(/style\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
|
||||
const className = (text.match(/class\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
|
||||
stack.push({
|
||||
tagName,
|
||||
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
|
||||
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
|
||||
});
|
||||
return stack;
|
||||
}
|
||||
|
||||
function getCanvasFont(metrics, fontPx, smallCaps, style) {
|
||||
return [
|
||||
style.italic ? 'italic' : '',
|
||||
smallCaps ? 'small-caps' : '',
|
||||
style.bold ? '700' : '',
|
||||
`${fontPx}px`,
|
||||
metrics.typography.fontFamily
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function applyTextStyle(ctx, metrics, fontPx, smallCaps, style) {
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||
ctx.font = getCanvasFont(metrics, fontPx, smallCaps, style);
|
||||
}
|
||||
|
||||
function buildLineSegments(ctx, nodes, line, ratio, baseStyle) {
|
||||
const segments = [];
|
||||
let x = 0;
|
||||
let currentSegment = null;
|
||||
let previousWasGlue = true;
|
||||
let currentWordIndex = -1;
|
||||
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
if (!node) return;
|
||||
if (node.type === 'box' && node.value) {
|
||||
const value = String(node.value);
|
||||
const width = Number(node.width || ctx.measureText(value).width || 0);
|
||||
const style = getInlineStyleState(styleStack, baseStyle);
|
||||
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
|
||||
currentSegment.value += value;
|
||||
currentSegment.width += width;
|
||||
} else {
|
||||
if (previousWasGlue) currentWordIndex += 1;
|
||||
currentSegment = { value, x, width, wordIndex: Math.max(0, currentWordIndex), style };
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
x += width;
|
||||
previousWasGlue = false;
|
||||
} else if (node.type === 'glue' && node.width !== 0) {
|
||||
let width = Number(node.width || 0);
|
||||
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
|
||||
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
|
||||
x += width;
|
||||
previousWasGlue = true;
|
||||
currentSegment = null;
|
||||
} else if (node.type === 'penalty' && node.penalty === 100) {
|
||||
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
|
||||
if (isLineEndHyphen) {
|
||||
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
|
||||
currentSegment.value += '-';
|
||||
currentSegment.width += hyphenWidth;
|
||||
x += hyphenWidth;
|
||||
}
|
||||
previousWasGlue = false;
|
||||
} else if (node.type === 'tag') {
|
||||
updateInlineStyleState(styleStack, node.value);
|
||||
}
|
||||
});
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function drawLine(ctx, metrics, lineRecord, side) {
|
||||
const content = getPageContent(metrics, side);
|
||||
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
||||
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
|
||||
const line = lineRecord.line || {};
|
||||
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
||||
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
|
||||
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
|
||||
const naturalWidth = nodes.reduce((sum, node) => {
|
||||
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
|
||||
return sum;
|
||||
}, 0);
|
||||
const centerOffset = line.align === 'center'
|
||||
? Math.max(0, (content.width - naturalWidth) / 2)
|
||||
: Number(line.offset || 0);
|
||||
const x = content.x + centerOffset;
|
||||
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
|
||||
const baseStyle = getInlineStyleState(line.activeStyleTags || [], { italic: lineRecord.fontStyle === 'italic' });
|
||||
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
||||
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
|
||||
if (lineRecord.dropCapText) {
|
||||
ctx.save();
|
||||
const dropCapFontPx = Math.round(fontPx * 2.68);
|
||||
const dropCapX = content.x;
|
||||
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
||||
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
|
||||
ctx.restore();
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
||||
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
|
||||
}
|
||||
buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
|
||||
applyTextStyle(ctx, metrics, fontPx, smallCaps, segment.style || {});
|
||||
ctx.fillText(segment.value || '', x + segment.x, baseY);
|
||||
});
|
||||
}
|
||||
|
||||
function drawImageFitted(ctx, bitmap, x, y, width, height) {
|
||||
const sourceWidth = bitmap.width || 1;
|
||||
const sourceHeight = bitmap.height || 1;
|
||||
const sourceAspect = sourceWidth / sourceHeight;
|
||||
const targetAspect = width / height;
|
||||
let sx = 0, sy = 0, sw = sourceWidth, sh = sourceHeight;
|
||||
if (sourceAspect > targetAspect) {
|
||||
sw = sourceHeight * targetAspect;
|
||||
sx = (sourceWidth - sw) * 0.5;
|
||||
} else if (sourceAspect < targetAspect) {
|
||||
sh = sourceWidth / targetAspect;
|
||||
sy = (sourceHeight - sh) * 0.5;
|
||||
}
|
||||
ctx.drawImage(bitmap, sx, sy, sw, sh, x, y, width, height);
|
||||
}
|
||||
|
||||
function drawImageRecord(ctx, metrics, lineRecord, side) {
|
||||
const content = getPageContent(metrics, side);
|
||||
const layout = lineRecord.metadata?.imageLayout || {};
|
||||
const rect = layout.textureRect || {};
|
||||
const x = content.x + Number(rect.x || 0);
|
||||
const y = content.y + Number(rect.y || 0);
|
||||
const width = Math.max(1, Number(rect.width || content.width));
|
||||
const height = Math.max(1, Number(rect.height || metrics.typographyLineHeightPx));
|
||||
const bitmap = imageCache.get(resolveImageSource(lineRecord.metadata || {}));
|
||||
if (!bitmap) return;
|
||||
ctx.save();
|
||||
drawImageFitted(ctx, bitmap, x, y, width, height);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPageBase(ctx, side, width, height) {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.fillStyle = '#f2ead0';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
const shade = ctx.createLinearGradient(0, 0, width, 0);
|
||||
if (side === 'left') {
|
||||
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
|
||||
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
|
||||
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
|
||||
} else {
|
||||
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
|
||||
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
|
||||
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
|
||||
}
|
||||
ctx.fillStyle = shade;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
function drawTitlePage(ctx, metrics, side, titleData) {
|
||||
if (!titleData) return;
|
||||
const content = getPageContent(metrics, side);
|
||||
const centerX = content.x + content.width * 0.5;
|
||||
const font = metrics.typography.fontFamily;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||
if (titleData.author) {
|
||||
ctx.font = `italic ${Math.round(metrics.bodyFontSizePx * 0.86)}px ${font}`;
|
||||
ctx.fillText(titleData.author, centerX, content.y + content.height * 0.18);
|
||||
}
|
||||
if (titleData.title) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.55)}px ${font}`;
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
|
||||
ctx.fillText(titleData.title, centerX, content.y + content.height * 0.28);
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||
}
|
||||
if (titleData.subtitle) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.94)}px ${font}`;
|
||||
ctx.fillText(titleData.subtitle, centerX, content.y + content.height * 0.39);
|
||||
}
|
||||
if (titleData.ornament) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.3)}px ${font}`;
|
||||
ctx.fillText(titleData.ornament, centerX, content.y + content.height * 0.52);
|
||||
}
|
||||
if (titleData.legal) {
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.62)}px ${font}`;
|
||||
ctx.fillText(titleData.legal, centerX, content.y + content.height * 0.96);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPageNumber(ctx, metrics, side, meta) {
|
||||
if (!meta || meta.omitPageNumber || meta.pageNumber == null) return;
|
||||
const content = getPageContent(metrics, side);
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.68)}px ${metrics.typography.fontFamily}`;
|
||||
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + metrics.margins.bottom * 0.48);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPageLines(ctx, metrics, side, lines) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||
(Array.isArray(lines) ? lines : []).forEach(line => {
|
||||
if (line?.type === 'image' || line?.kind === 'image') drawImageRecord(ctx, metrics, line, side);
|
||||
else drawLine(ctx, metrics, line, side);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
async function renderSide(job, side) {
|
||||
const { metrics, width, height } = job;
|
||||
const surface = getSurface(width, height);
|
||||
const ctx = surface.ctx;
|
||||
const meta = job.pageMeta?.[side] || null;
|
||||
|
||||
drawPageBase(ctx, side, width, height);
|
||||
let baseBitmap = null;
|
||||
const baseKey = `${side}:${width}x${height}`;
|
||||
if (job.hasReveal && !sentBaseKeys.has(baseKey)) {
|
||||
baseBitmap = await createImageBitmap(surface.canvas);
|
||||
sentBaseKeys.add(baseKey);
|
||||
}
|
||||
if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData);
|
||||
drawPageLines(ctx, metrics, side, job.spreads?.[side] || []);
|
||||
drawPageNumber(ctx, metrics, side, meta);
|
||||
const pageBitmap = await createImageBitmap(surface.canvas);
|
||||
return { pageBitmap, baseBitmap };
|
||||
}
|
||||
|
||||
function collectImageSources(job) {
|
||||
const srcs = new Set();
|
||||
(job.sides || ['left', 'right']).forEach((side) => {
|
||||
(job.spreads?.[side] || []).forEach((line) => {
|
||||
if (line?.type === 'image' || line?.kind === 'image') {
|
||||
const src = resolveImageSource(line.metadata || {});
|
||||
if (src) srcs.add(src);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(srcs);
|
||||
}
|
||||
|
||||
async function handleDraw(job) {
|
||||
await ensureFonts();
|
||||
await ensureImages(collectImageSources(job));
|
||||
const results = {};
|
||||
const transfer = [];
|
||||
for (const side of (job.sides || ['left', 'right'])) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const { pageBitmap, baseBitmap } = await renderSide(job, side);
|
||||
results[side] = { pageBitmap, baseBitmap };
|
||||
transfer.push(pageBitmap);
|
||||
if (baseBitmap) transfer.push(baseBitmap);
|
||||
}
|
||||
self.postMessage({ type: 'drawn', requestId: job.requestId, results }, transfer);
|
||||
}
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const data = event.data || {};
|
||||
if (data.type === 'draw') handleDraw(data);
|
||||
else if (data.type === 'warm-fonts') ensureFonts().then(() => self.postMessage({ type: 'fonts-ready' }));
|
||||
};
|
||||
@@ -20,6 +20,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
this.currentTurnId = 0;
|
||||
this.autoTurnCounter = 0;
|
||||
this.lastAutoTurn = new Map();
|
||||
this.selectionInProgress = false;
|
||||
this.template = {
|
||||
cells: {
|
||||
default: {
|
||||
@@ -136,6 +137,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
};
|
||||
this.currentGlossaryEntries = detail.glossaryEntries;
|
||||
this.choices = this.normalizeChoices(detail.choices);
|
||||
this.selectionInProgress = false;
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -159,7 +161,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
|
||||
if (event.repeat || event.ctrlKey || event.metaKey || event.altKey || event.key.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -434,6 +436,9 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
}
|
||||
|
||||
async selectChoice(index) {
|
||||
if (this.selectionInProgress) {
|
||||
return;
|
||||
}
|
||||
if (!this.socketClient) {
|
||||
this.socketClient = this.getModule('socket-client');
|
||||
}
|
||||
@@ -442,6 +447,7 @@ class ChoiceDisplayModule extends BaseModule {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectionInProgress = true;
|
||||
this.clear();
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index }
|
||||
|
||||
@@ -54,7 +54,9 @@ class GameLoopModule extends BaseModule {
|
||||
'requestStartGame',
|
||||
'requestSaveGame',
|
||||
'requestLoadGame',
|
||||
'resetClientPlaybackAndDisplay'
|
||||
'resetClientPlaybackAndDisplay',
|
||||
'getWebGLBookState',
|
||||
'applyWebGLBookState'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -74,7 +76,7 @@ class GameLoopModule extends BaseModule {
|
||||
return true;
|
||||
}
|
||||
|
||||
start() {
|
||||
async start() {
|
||||
console.log("GameLoop: Starting game sequence...");
|
||||
|
||||
try {
|
||||
@@ -85,12 +87,14 @@ class GameLoopModule extends BaseModule {
|
||||
console.log("GameLoop: Setting up socket listeners and connecting...");
|
||||
|
||||
// Set up socket event listeners and connect
|
||||
this.setupSocketEventListeners();
|
||||
const connected = await this.setupSocketEventListeners();
|
||||
|
||||
// Set the game loop as running
|
||||
this.isRunning = true;
|
||||
return connected;
|
||||
} catch (error) {
|
||||
console.error("Error starting game loop:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +135,7 @@ class GameLoopModule extends BaseModule {
|
||||
|
||||
if (!socketClient) {
|
||||
console.error("Socket client module not found");
|
||||
return;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// Connect UI controller to socket client for command handling
|
||||
@@ -179,12 +183,13 @@ class GameLoopModule extends BaseModule {
|
||||
});
|
||||
|
||||
// Connect to the socket server
|
||||
socketClient.connect().then(success => {
|
||||
return socketClient.connect().then(success => {
|
||||
if (success) {
|
||||
console.log("GameLoop: Socket connection established successfully.");
|
||||
} else {
|
||||
console.error("GameLoop: Failed to connect to socket server");
|
||||
}
|
||||
return success;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,6 +327,7 @@ class GameLoopModule extends BaseModule {
|
||||
if (typeof storyHistory.saveSlot === 'function') {
|
||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
||||
inkState: null,
|
||||
webglBookState: this.getWebGLBookState(),
|
||||
choices: [],
|
||||
inputMode: 'none',
|
||||
running: false
|
||||
@@ -347,6 +353,7 @@ class GameLoopModule extends BaseModule {
|
||||
if (!isCurrentOperation()) return;
|
||||
await storyHistory.saveSlot(this.autoSaveSlot, {
|
||||
inkState: response.savedState,
|
||||
webglBookState: this.getWebGLBookState(),
|
||||
choices: [],
|
||||
inputMode: 'none',
|
||||
running: true
|
||||
@@ -372,6 +379,7 @@ class GameLoopModule extends BaseModule {
|
||||
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
|
||||
renderedLineCount: storyHistory.renderedLineCount || 0,
|
||||
musicState: audioManager?.getMusicState?.() || null,
|
||||
webglBookState: this.getWebGLBookState(),
|
||||
choices: this.currentChoices,
|
||||
inputMode: this.currentInputMode,
|
||||
running: this.gameState.started && !this.gameState.ended
|
||||
@@ -453,6 +461,7 @@ class GameLoopModule extends BaseModule {
|
||||
browserSave.renderedLineCount || 0
|
||||
);
|
||||
}
|
||||
this.applyWebGLBookState(browserSave.webglBookState);
|
||||
const uiController = this.getModule('ui-controller');
|
||||
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
|
||||
await uiController.displayHandler.restoreFromHistory(browserSave);
|
||||
@@ -516,6 +525,15 @@ class GameLoopModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
getWebGLBookState() {
|
||||
return window.WebGLBookPreferenceBridge?.getBookState?.() || null;
|
||||
}
|
||||
|
||||
applyWebGLBookState(state = null) {
|
||||
if (!state || typeof state !== 'object') return;
|
||||
window.WebGLBookPreferenceBridge?.applyBookState?.(state);
|
||||
}
|
||||
|
||||
hasUnrenderedHistory(browserSave) {
|
||||
return Boolean(browserSave) &&
|
||||
Number(browserSave.latestBlockId || 0) > Number(browserSave.latestRenderedBlockId || 0);
|
||||
@@ -565,6 +583,7 @@ class GameLoopModule extends BaseModule {
|
||||
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
|
||||
renderedLineCount: storyHistory.renderedLineCount || 0,
|
||||
musicState: audioManager?.getMusicState?.() || null,
|
||||
webglBookState: this.getWebGLBookState(),
|
||||
choices: this.currentChoices,
|
||||
inputMode: this.currentInputMode,
|
||||
running: this.gameState.started && !this.gameState.ended
|
||||
@@ -638,6 +657,13 @@ class GameLoopModule extends BaseModule {
|
||||
if (inputHandler && typeof inputHandler.clearHistory === 'function') {
|
||||
inputHandler.clearHistory();
|
||||
}
|
||||
|
||||
// Signal a client reset so transient, block-id-keyed reveal/animation state is
|
||||
// cleared. Without this, a new game that reuses block ids over already-cached
|
||||
// content keeps the previous run's reveal start times and skips the animation.
|
||||
document.dispatchEvent(new CustomEvent('story:client-reset', {
|
||||
detail: { reason: 'client-reset' }
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const ModuleState = {
|
||||
ERROR: 'ERROR'
|
||||
};
|
||||
|
||||
const MODULE_CACHE_BUSTER = '20260516-scroll-window';
|
||||
const MODULE_CACHE_BUSTER = '20260610-book-timeline-l';
|
||||
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
|
||||
|
||||
/**
|
||||
@@ -113,8 +113,14 @@ 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: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 },
|
||||
{ id: 'webgl-page-cache', script: '/js/webgl-page-cache-module.js', weight: 5 },
|
||||
{ id: 'book-pagination', script: '/js/book-pagination-module.js', weight: 8 },
|
||||
{ id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 },
|
||||
{ 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
|
||||
{ id: 'book-playback-timeline', script: '/js/book-playback-timeline-module.js', weight: 8 },
|
||||
|
||||
// Audio and TTS modules
|
||||
{ id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 },
|
||||
@@ -824,17 +830,17 @@ const ModuleLoader = (function() {
|
||||
async function completeFinalization() {
|
||||
isLoadingComplete = true;
|
||||
|
||||
// Call the start method on the game loop module directly
|
||||
// Ensure the game loop module was found during initialization
|
||||
// Call the start method on the game loop module directly.
|
||||
// Starting before hiding the overlay lets socket connection and
|
||||
// save/resume state settle as part of the loader handoff.
|
||||
if (gameLoopModule && typeof gameLoopModule.start === 'function') {
|
||||
// Hide the overlay first, then start the game loop
|
||||
await hideOverlay();
|
||||
console.log("Loader: Overlay hidden, starting Game Loop.");
|
||||
try {
|
||||
gameLoopModule.start();
|
||||
console.log("Loader: Starting Game Loop before hiding overlay.");
|
||||
await gameLoopModule.start();
|
||||
} catch (error) {
|
||||
console.error("Error starting Game Loop:", error);
|
||||
}
|
||||
await hideOverlay();
|
||||
} else {
|
||||
console.error("Loader: Game Loop module not found or start method missing.");
|
||||
// Hide overlay anyway, but log error
|
||||
@@ -883,12 +889,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');
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class MarkupParserModule extends BaseModule {
|
||||
'parseImageOptions',
|
||||
'parseSfxOptions',
|
||||
'parseMusicOptions',
|
||||
'parsePageReserveDirective',
|
||||
'markdownToHtml',
|
||||
'markdownToPlainText',
|
||||
'smartypants',
|
||||
@@ -89,7 +90,7 @@ class MarkupParserModule extends BaseModule {
|
||||
const lower = token.toLowerCase();
|
||||
const [key, value] = lower.split('=');
|
||||
|
||||
if (['landscape', 'widescreen', 'portrait', 'square'].includes(lower)) {
|
||||
if (['landscape', 'widescreen', 'portrait', 'square', 'full'].includes(lower)) {
|
||||
options.size = lower === 'widescreen' ? 'landscape' : lower;
|
||||
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
|
||||
const seconds = Number(value);
|
||||
@@ -178,11 +179,14 @@ class MarkupParserModule extends BaseModule {
|
||||
}
|
||||
|
||||
parseParagraph(rawText) {
|
||||
const inline = this.parseInline(this.normalizeParagraph(rawText));
|
||||
const normalized = this.normalizeParagraph(rawText);
|
||||
const reserveDirective = this.parsePageReserveDirective(normalized);
|
||||
const inline = this.parseInline(reserveDirective.text);
|
||||
return {
|
||||
text: this.markdownToPlainText(inline.text),
|
||||
layoutText: this.markdownToHtml(inline.text),
|
||||
cueMarkers: inline.cueMarkers
|
||||
cueMarkers: inline.cueMarkers,
|
||||
pageReserve: reserveDirective.directive
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,12 +197,34 @@ class MarkupParserModule extends BaseModule {
|
||||
layoutText: paragraph.layoutText,
|
||||
cueMarkers: paragraph.cueMarkers,
|
||||
role,
|
||||
metadata: {
|
||||
...(paragraph.pageReserve ? { pageReserve: paragraph.pageReserve } : {})
|
||||
},
|
||||
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
|
||||
dropCap: role === 'chapter-first',
|
||||
addTopSpace: role === 'textblock-first'
|
||||
};
|
||||
}
|
||||
|
||||
parsePageReserveDirective(text) {
|
||||
const source = String(text || '');
|
||||
const match = source.match(/#pagereserve\[\s*([0-9]+(?:\.[0-9]+)?)\s*(%)?\s*\]/i);
|
||||
if (!match) {
|
||||
return { text: source, directive: null };
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
const directive = Number.isFinite(value)
|
||||
? {
|
||||
value,
|
||||
unit: match[2] === '%' ? 'percent' : 'pages'
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
text: source.replace(match[0], '').replace(/\s{2,}/g, ' ').trim(),
|
||||
directive
|
||||
};
|
||||
}
|
||||
|
||||
parseInline(text) {
|
||||
return {
|
||||
text: String(text || '').replace(/\s{2,}/g, ' ').trim(),
|
||||
|
||||
@@ -50,10 +50,14 @@ class OptionsUIModule extends BaseModule {
|
||||
'setupApiUrlFields',
|
||||
'setupInitialState',
|
||||
'dispatchApiChangeEvent',
|
||||
'getMetadataNumber',
|
||||
'hasFixedBookPageCount',
|
||||
'hasFixedPageReserve',
|
||||
'getPreference',
|
||||
'updatePreference',
|
||||
'updateUIText',
|
||||
'renderProviderStatuses'
|
||||
'renderProviderStatuses',
|
||||
'updateWebGLDisplays'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -92,6 +96,25 @@ class OptionsUIModule extends BaseModule {
|
||||
}));
|
||||
}
|
||||
|
||||
getMetadataNumber(keys = []) {
|
||||
const gameConfig = this.getModule('game-config');
|
||||
const metadata = gameConfig?.getMetadata?.() || {};
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, key)) continue;
|
||||
const value = Number(metadata[key]);
|
||||
if (Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
hasFixedBookPageCount() {
|
||||
return Number.isFinite(this.getMetadataNumber(['bookPageCount', 'defaultBookPageCount', 'webglBookPageCount']));
|
||||
}
|
||||
|
||||
hasFixedPageReserve() {
|
||||
return Number.isFinite(this.getMetadataNumber(['pageReserve', 'defaultPageReserve', 'webglPageReserve']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a preference from the persistence manager
|
||||
* @param {string} category - Preference category
|
||||
@@ -250,6 +273,90 @@ class OptionsUIModule extends BaseModule {
|
||||
|
||||
body.appendChild(appSettingsSection);
|
||||
|
||||
const webglSection = document.createElement('div');
|
||||
webglSection.className = 'options-section';
|
||||
|
||||
const webglTitle = document.createElement('h3');
|
||||
webglTitle.textContent = this.t('options.bookDisplay');
|
||||
webglSection.appendChild(webglTitle);
|
||||
|
||||
const displayModeContainer = document.createElement('div');
|
||||
displayModeContainer.className = 'option-item';
|
||||
|
||||
const displayModeLabel = document.createElement('label');
|
||||
displayModeLabel.textContent = this.t('options.displayMode') + ':';
|
||||
displayModeContainer.appendChild(displayModeLabel);
|
||||
|
||||
this.elements.webglMode = createUIElement('select', {
|
||||
'data-pref-bind': 'webgl.mode'
|
||||
}, null, displayModeContainer);
|
||||
[
|
||||
{ value: '3d', label: this.t('options.displayMode3d') },
|
||||
{ value: '2d', label: this.t('options.displayMode2d') }
|
||||
].forEach((optionConfig) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = optionConfig.value;
|
||||
option.textContent = optionConfig.label;
|
||||
this.elements.webglMode.appendChild(option);
|
||||
});
|
||||
webglSection.appendChild(displayModeContainer);
|
||||
|
||||
const bookSizeContainer = document.createElement('div');
|
||||
bookSizeContainer.className = 'option-item';
|
||||
|
||||
const bookSizeLabel = document.createElement('label');
|
||||
bookSizeLabel.textContent = this.t('options.bookSize') + ':';
|
||||
bookSizeContainer.appendChild(bookSizeLabel);
|
||||
|
||||
const bookSizeValue = document.createElement('span');
|
||||
bookSizeValue.className = 'slider-value';
|
||||
bookSizeValue.textContent = '300';
|
||||
this.elements.webglBookSizeValue = bookSizeValue;
|
||||
bookSizeContainer.appendChild(bookSizeValue);
|
||||
|
||||
this.elements.webglBookSize = createUIElement('input', {
|
||||
type: 'range',
|
||||
min: 40,
|
||||
max: 500,
|
||||
step: 10,
|
||||
value: 300,
|
||||
'data-pref-bind': 'webgl.bookPageCount',
|
||||
'data-pref-transform': 'integer:40,500'
|
||||
}, null, bookSizeContainer);
|
||||
this.elements.webglBookSize.addEventListener('input', () => this.updateWebGLDisplays());
|
||||
if (!this.hasFixedBookPageCount()) {
|
||||
webglSection.appendChild(bookSizeContainer);
|
||||
}
|
||||
|
||||
const pageReserveContainer = document.createElement('div');
|
||||
pageReserveContainer.className = 'option-item';
|
||||
|
||||
const pageReserveLabel = document.createElement('label');
|
||||
pageReserveLabel.textContent = this.t('options.pageReserve') + ':';
|
||||
pageReserveContainer.appendChild(pageReserveLabel);
|
||||
|
||||
const pageReserveValue = document.createElement('span');
|
||||
pageReserveValue.className = 'slider-value';
|
||||
pageReserveValue.textContent = '50';
|
||||
this.elements.webglPageReserveValue = pageReserveValue;
|
||||
pageReserveContainer.appendChild(pageReserveValue);
|
||||
|
||||
this.elements.webglPageReserve = createUIElement('input', {
|
||||
type: 'range',
|
||||
min: 0,
|
||||
max: 500,
|
||||
step: 1,
|
||||
value: 50,
|
||||
'data-pref-bind': 'webgl.pageReserve',
|
||||
'data-pref-transform': 'integer:0,500'
|
||||
}, null, pageReserveContainer);
|
||||
this.elements.webglPageReserve.addEventListener('input', () => this.updateWebGLDisplays());
|
||||
if (!this.hasFixedPageReserve()) {
|
||||
webglSection.appendChild(pageReserveContainer);
|
||||
}
|
||||
|
||||
body.appendChild(webglSection);
|
||||
|
||||
// TTS Section
|
||||
const ttsSection = document.createElement('div');
|
||||
ttsSection.className = 'options-section';
|
||||
@@ -1020,6 +1127,7 @@ class OptionsUIModule extends BaseModule {
|
||||
console.log('Options UI: Preference bindings set up', this.bindings.length);
|
||||
this.updateSpeedDisplay();
|
||||
this.updateVolumeDisplays();
|
||||
this.updateWebGLDisplays();
|
||||
|
||||
// Add event listeners for side effects when preferences change
|
||||
document.addEventListener('preference-updated', (event) => {
|
||||
@@ -1115,6 +1223,10 @@ class OptionsUIModule extends BaseModule {
|
||||
this.populateVoices();
|
||||
}
|
||||
}
|
||||
|
||||
if (category === 'webgl') {
|
||||
this.updateWebGLDisplays();
|
||||
}
|
||||
if (key === 'speed' && this.elements.ttsSpeed) {
|
||||
this.updateSpeedDisplay();
|
||||
}
|
||||
@@ -1155,6 +1267,21 @@ class OptionsUIModule extends BaseModule {
|
||||
this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`;
|
||||
}
|
||||
}
|
||||
|
||||
updateWebGLDisplays() {
|
||||
if (this.elements.webglBookSize && this.elements.webglBookSizeValue) {
|
||||
this.elements.webglBookSizeValue.textContent = String(this.elements.webglBookSize.value);
|
||||
}
|
||||
if (this.elements.webglPageReserve && this.elements.webglPageReserveValue) {
|
||||
const bookSize = Number(this.elements.webglBookSize?.value || this.getPreference('webgl', 'bookPageCount', 300));
|
||||
const maxReserve = Number.isFinite(bookSize) ? Math.max(0, Math.floor(bookSize)) : 500;
|
||||
this.elements.webglPageReserve.max = String(maxReserve);
|
||||
if (Number(this.elements.webglPageReserve.value) > maxReserve) {
|
||||
this.elements.webglPageReserve.value = String(maxReserve);
|
||||
}
|
||||
this.elements.webglPageReserveValue.textContent = String(this.elements.webglPageReserve.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the singleton instance
|
||||
|
||||
@@ -67,6 +67,12 @@ class PersistenceManagerModule extends BaseModule {
|
||||
localeUserOverride: false,
|
||||
speed: 1.0,
|
||||
autoplay: true,
|
||||
},
|
||||
webgl: {
|
||||
mode: null,
|
||||
bookPageCount: 300,
|
||||
bookProgress: 0,
|
||||
pageReserve: 50
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
'play',
|
||||
'calculateWordTimings',
|
||||
'animateWords',
|
||||
'isWebGLPlaybackMode',
|
||||
'scheduleWebGLReveal',
|
||||
'waitForAudioStart',
|
||||
'completeSentenceVisual',
|
||||
'accelerateActiveWordAnimations',
|
||||
@@ -213,7 +215,7 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
* @returns {Promise<void>} - Resolves when animation completes
|
||||
*/
|
||||
async animateWords(sentence) {
|
||||
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) {
|
||||
if (!sentence.animation || !sentence.animation.wordTimings) {
|
||||
console.error('PlaybackCoordinator: Missing animation data');
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -224,6 +226,15 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.isWebGLPlaybackMode()) {
|
||||
return this.scheduleWebGLReveal(sentence, animQueue);
|
||||
}
|
||||
|
||||
if (!sentence.element) {
|
||||
console.error('PlaybackCoordinator: Missing DOM element for 2D animation');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const wordElements = sentence.element.querySelectorAll('.word');
|
||||
let wordTimings = sentence.animation.wordTimings;
|
||||
let cueTimings = sentence.animation.cueTimings || [];
|
||||
@@ -241,7 +252,6 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const totalDuration = wordTimings.length > 0
|
||||
? Math.max(...wordTimings.map(timing => timing.delay + timing.duration))
|
||||
@@ -293,6 +303,55 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
isWebGLPlaybackMode() {
|
||||
return document.body?.dataset?.webglUiMode === '3d'
|
||||
|| document.body?.classList?.contains('webgl-mode');
|
||||
}
|
||||
|
||||
scheduleWebGLReveal(sentence, animQueue) {
|
||||
// The book playback timeline is the single owner of reveal timing. It guarantees
|
||||
// sentence.animation is populated (ensureAnimationTimings) before playback. The
|
||||
// coordinator trusts those timings and never recomputes them here.
|
||||
const wordTimings = Array.isArray(sentence.animation?.wordTimings)
|
||||
? sentence.animation.wordTimings
|
||||
: [];
|
||||
const cueTimings = Array.isArray(sentence.animation?.cueTimings)
|
||||
? sentence.animation.cueTimings
|
||||
: [];
|
||||
|
||||
if (typeof sentence.webglRevealController !== 'function') {
|
||||
throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller');
|
||||
}
|
||||
sentence.webglRevealController({
|
||||
id: sentence.id,
|
||||
blockId: sentence.blockId ?? sentence.metadata?.blockId ?? null,
|
||||
wordTimings,
|
||||
cueTimings,
|
||||
totalDuration: sentence.animation?.totalDuration || 0
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const totalDuration = wordTimings.length > 0
|
||||
? Math.max(...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
|
||||
: 0;
|
||||
|
||||
cueTimings.forEach(cue => {
|
||||
animQueue.schedule(() => {
|
||||
document.dispatchEvent(new CustomEvent('story:media-cue', {
|
||||
detail: {
|
||||
sentenceId: sentence.id,
|
||||
...cue
|
||||
}
|
||||
}));
|
||||
}, cue.delay || 0);
|
||||
});
|
||||
|
||||
animQueue.schedule(() => {
|
||||
resolve();
|
||||
}, totalDuration + 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate word-level timing to match total TTS duration
|
||||
* This is a utility method that can be called by SentenceQueue during preparation
|
||||
@@ -350,6 +409,12 @@ class PlaybackCoordinatorModule extends BaseModule {
|
||||
|
||||
console.log('PlaybackCoordinator: Fast forwarding');
|
||||
this.accelerateActiveWordAnimations(this.currentSentence);
|
||||
document.dispatchEvent(new CustomEvent('book-texture:fast-forward', {
|
||||
detail: {
|
||||
id: this.currentSentence?.id,
|
||||
blockId: this.currentSentence?.blockId ?? this.currentSentence?.metadata?.blockId ?? null
|
||||
}
|
||||
}));
|
||||
|
||||
const animQueue = this.getModule('animation-queue');
|
||||
if (animQueue) {
|
||||
|
||||
@@ -7,21 +7,27 @@ import { BaseModule } from './base-module.js';
|
||||
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
||||
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
||||
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
||||
// Prepare only the next block's page render ahead of playback. Higher values let multiple
|
||||
// large page rasterizations overlap, spiking allocation into multi-second GC stalls.
|
||||
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1;
|
||||
|
||||
class SentenceQueueModule extends BaseModule {
|
||||
constructor() {
|
||||
super('sentence-queue', 'Sentence Queue');
|
||||
|
||||
// Dependencies
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager'];
|
||||
this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager', 'book-playback-timeline'];
|
||||
|
||||
// Queue state
|
||||
this.sentenceQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.onSentenceReadyCallback = null;
|
||||
|
||||
// Cache in-flight TTS prefetches only. Layout belongs to the renderer.
|
||||
// Cache prepared future queue items so the playback path can consume
|
||||
// work that was already generated during lookahead.
|
||||
this.prefetchingSpeech = new Map();
|
||||
this.prefetchingWebGLBook = new Map();
|
||||
this.preparedSentenceCache = new Map();
|
||||
this.autoplay = true;
|
||||
this.inputMode = 'text';
|
||||
this.lastContinueAt = 0;
|
||||
@@ -31,6 +37,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.generationRequests = new Map();
|
||||
this.assetPreloadRequests = new Map();
|
||||
this.queueGeneration = 0;
|
||||
this.webglBookPrepareChain = Promise.resolve();
|
||||
|
||||
// Bind methods
|
||||
this.bindMethods([
|
||||
@@ -43,6 +50,11 @@ class SentenceQueueModule extends BaseModule {
|
||||
'getCacheKey',
|
||||
'getPreparedSentence',
|
||||
'prefetchAhead',
|
||||
'prefetchWebGLBookPresentation',
|
||||
'runWebGLBookPresentationPrepare',
|
||||
'isWebGLBookPresentationPrepared',
|
||||
'getWebGLBookPresentationKey',
|
||||
'isWebGLBookPresentationEligible',
|
||||
'prepareSpeechMetadata',
|
||||
'preloadAssetsForItem',
|
||||
'normalizeTtsText',
|
||||
@@ -156,9 +168,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
text: String(queueItem.text || '').trim()
|
||||
});
|
||||
|
||||
// Process the queue if not already processing
|
||||
// Process the queue if not already processing. If playback is already
|
||||
// running, immediately start lookahead for the newly appended item.
|
||||
if (!this.isProcessing) {
|
||||
this.processNextSentence();
|
||||
} else {
|
||||
this.prefetchAhead(6, this.queueGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,19 +209,27 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
const sentence = await this.getPreparedSentence(item);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
|
||||
// Prefetch far enough ahead that media pauses do not block TTS
|
||||
// generation for the next spoken paragraph.
|
||||
this.prefetchAhead(4, queueGeneration);
|
||||
if (!this.isWebGLBookPresentationPrepared(sentence)) {
|
||||
await this.prefetchWebGLBookPresentation(sentence, {
|
||||
queueGeneration,
|
||||
queueIndex: 0,
|
||||
immediate: true
|
||||
});
|
||||
}
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
|
||||
// Notify display handler with complete sentence
|
||||
if (this.onSentenceReadyCallback) {
|
||||
await new Promise(resolve => {
|
||||
const playbackFinished = new Promise(resolve => {
|
||||
sentence.onComplete = resolve;
|
||||
sentence.playbackStartedAt = performance.now();
|
||||
this.onSentenceReadyCallback(sentence, resolve);
|
||||
});
|
||||
this.scheduleLookaheadAfterDisplay(item, queueGeneration);
|
||||
await playbackFinished;
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
} else {
|
||||
this.prefetchAhead(6, queueGeneration);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
}
|
||||
|
||||
@@ -499,14 +522,15 @@ class SentenceQueueModule extends BaseModule {
|
||||
* Prepare queue metadata. This module intentionally does not create layout:
|
||||
* live rendering and history rendering must go through the same renderer.
|
||||
*/
|
||||
async prepareSentence(item) {
|
||||
async prepareSentence(item, options = {}) {
|
||||
const text = typeof item === 'string' ? item : item.text;
|
||||
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const metadata = typeof item === 'object' && item !== null ? item : {};
|
||||
const blocking = options.blocking !== false;
|
||||
|
||||
try {
|
||||
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) {
|
||||
await this.preloadAssetsForItem(metadata, { blocking: true, sentenceId: id });
|
||||
await this.preloadAssetsForItem(metadata, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -529,7 +553,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
await this.preloadAssetsForItem({
|
||||
type: 'paragraph',
|
||||
cueMarkers: metadata.cueMarkers || []
|
||||
}, { blocking: true, sentenceId: id });
|
||||
}, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
|
||||
}
|
||||
|
||||
const ttsData = await this.prepareSpeechMetadata(text, {
|
||||
@@ -537,7 +561,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
blockId: metadata.blockId ?? null,
|
||||
turnId: metadata.turnId ?? null,
|
||||
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
|
||||
blocking: true
|
||||
blocking
|
||||
});
|
||||
|
||||
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
|
||||
@@ -834,7 +858,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
resolve();
|
||||
};
|
||||
const onCommand = (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
if (event.detail?.type === 'continue' && !this.isChoiceAwaitingPlayer()) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
@@ -846,20 +870,122 @@ class SentenceQueueModule extends BaseModule {
|
||||
return `${item?.id || ''}:${item?.text || ''}`;
|
||||
}
|
||||
|
||||
isChoiceAwaitingPlayer() {
|
||||
if (this.inputMode !== 'choice') {
|
||||
return false;
|
||||
}
|
||||
const choicePanel = document.getElementById('story_choices');
|
||||
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
|
||||
}
|
||||
|
||||
async getPreparedSentence(item) {
|
||||
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
|
||||
const cacheKey = this.getCacheKey(item);
|
||||
const prepared = this.preparedSentenceCache.get(cacheKey);
|
||||
if (prepared) {
|
||||
this.preparedSentenceCache.delete(cacheKey);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
const pending = this.prefetchingSpeech.get(cacheKey);
|
||||
if (pending) {
|
||||
pending.catch(() => null);
|
||||
const prefetched = await pending.catch(() => null);
|
||||
if (prefetched) {
|
||||
this.preparedSentenceCache.delete(cacheKey);
|
||||
return prefetched;
|
||||
}
|
||||
}
|
||||
|
||||
return this.prepareSentence(item);
|
||||
}
|
||||
|
||||
getWebGLBookPresentationKey(sentence = {}) {
|
||||
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
||||
if (blockId == null) return null;
|
||||
return `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${blockId}`;
|
||||
}
|
||||
|
||||
isWebGLBookPresentationEligible(sentence = {}) {
|
||||
if (!sentence) return false;
|
||||
return ['paragraph', 'heading'].includes(sentence.kind || sentence.type);
|
||||
}
|
||||
|
||||
async prefetchWebGLBookPresentation(sentence, options = {}) {
|
||||
if (!this.isWebGLBookPresentationEligible(sentence)) return null;
|
||||
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|
||||
|| document.body?.classList?.contains('webgl-mode');
|
||||
if (!isWebGLMode) return null;
|
||||
|
||||
const key = this.getWebGLBookPresentationKey(sentence);
|
||||
if (!key) return null;
|
||||
const existing = this.prefetchingWebGLBook.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const queued = this.webglBookPrepareChain
|
||||
.catch(() => null)
|
||||
.then(() => this.runWebGLBookPresentationPrepare(sentence, options));
|
||||
this.webglBookPrepareChain = queued.catch(() => null);
|
||||
this.prefetchingWebGLBook.set(key, queued);
|
||||
return queued.finally(() => {
|
||||
if (this.prefetchingWebGLBook.get(key) === queued) {
|
||||
this.prefetchingWebGLBook.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async runWebGLBookPresentationPrepare(sentence, options = {}) {
|
||||
if (!this.isWebGLBookPresentationEligible(sentence)) return null;
|
||||
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
||||
if (blockId == null) return null;
|
||||
const bookPlaybackTimeline = this.getModule('book-playback-timeline');
|
||||
if (!bookPlaybackTimeline || typeof bookPlaybackTimeline.prepareSentence !== 'function') {
|
||||
throw new Error('SentenceQueue: 3D book presentation requires the book playback timeline');
|
||||
}
|
||||
if (!options.immediate) {
|
||||
await new Promise(resolve => {
|
||||
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
|
||||
scheduler(() => resolve(), { timeout: 80 });
|
||||
});
|
||||
}
|
||||
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
||||
const segment = await bookPlaybackTimeline.prepareSentence(sentence, {
|
||||
immediate: options.immediate === true
|
||||
});
|
||||
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
||||
if (!segment) return null;
|
||||
sentence.webglBookPresentation = {
|
||||
prepared: true,
|
||||
blockId,
|
||||
spread: segment.previewSpread || segment.activeSpread || null,
|
||||
timelineSegment: segment
|
||||
};
|
||||
return sentence.webglBookPresentation.spread;
|
||||
}
|
||||
|
||||
isWebGLBookPresentationPrepared(sentence) {
|
||||
const blockId = sentence?.blockId ?? sentence?.metadata?.blockId ?? null;
|
||||
if (blockId == null) return false;
|
||||
if (sentence?.webglBookPresentation?.prepared === true) return true;
|
||||
const bookPlaybackTimeline = this.getModule('book-playback-timeline');
|
||||
return Boolean(bookPlaybackTimeline?.preparedSegments?.has?.(`${sentence.gameId || sentence.metadata?.gameId || 'game'}:${blockId}`));
|
||||
}
|
||||
|
||||
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
|
||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||
}
|
||||
|
||||
prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) {
|
||||
scheduleLookaheadAfterDisplay(item, queueGeneration = this.queueGeneration) {
|
||||
const run = () => {
|
||||
if (this.isCurrentQueueItem(item, queueGeneration)) {
|
||||
this.prefetchAhead(6, queueGeneration);
|
||||
}
|
||||
};
|
||||
window.requestAnimationFrame(() => {
|
||||
const scheduleIdle = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 180));
|
||||
scheduleIdle(run, { timeout: 260 });
|
||||
});
|
||||
}
|
||||
|
||||
prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) {
|
||||
if (this.sentenceQueue.length <= 1) {
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
|
||||
@@ -869,14 +995,33 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
let started = 0;
|
||||
let spokenPrepared = 0;
|
||||
let webglBookLookahead = 0;
|
||||
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
|
||||
const allowWebGLBookPrefetch = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||
|
||||
for (let index = 1; index < limit; index += 1) {
|
||||
const nextItem = this.sentenceQueue[index];
|
||||
const nextCacheKey = this.getCacheKey(nextItem);
|
||||
const cachedPrepared = this.preparedSentenceCache.get(nextCacheKey);
|
||||
const webglBookCandidate = this.isWebGLBookPresentationEligible(cachedPrepared || nextItem);
|
||||
const shouldPrepareWebGLBook = allowWebGLBookPrefetch
|
||||
&& webglBookCandidate
|
||||
&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD;
|
||||
if (webglBookCandidate) webglBookLookahead += 1;
|
||||
|
||||
if (cachedPrepared && !this.prefetchingSpeech.has(nextCacheKey)) {
|
||||
if (shouldPrepareWebGLBook && !this.isWebGLBookPresentationPrepared(cachedPrepared)) {
|
||||
this.prefetchWebGLBookPresentation(cachedPrepared, {
|
||||
queueGeneration,
|
||||
queueIndex: index
|
||||
}).catch(err => {
|
||||
console.warn('SentenceQueue: WebGL book prefetch failed:', err);
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.prefetchingSpeech.has(nextCacheKey)) {
|
||||
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -888,35 +1033,30 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
const promise = (async () => {
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
await this.preloadAssetsForItem(nextItem, {
|
||||
sentenceId: nextItem.id,
|
||||
const prepared = await this.prepareSentence(nextItem, {
|
||||
blocking: false,
|
||||
prefetch: true
|
||||
prefetch: true,
|
||||
queueIndex: index
|
||||
});
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
|
||||
if (!this.isSpeechItem(nextItem)) {
|
||||
return null;
|
||||
if (shouldPrepareWebGLBook) {
|
||||
await this.prefetchWebGLBookPresentation(prepared, {
|
||||
queueGeneration,
|
||||
queueIndex: index
|
||||
});
|
||||
}
|
||||
|
||||
return this.prepareSpeechMetadata(nextItem.text || '', {
|
||||
sentenceId: nextItem.id,
|
||||
blockId: nextItem.blockId ?? null,
|
||||
turnId: nextItem.turnId ?? null,
|
||||
ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [],
|
||||
queueIndex: index,
|
||||
prefetch: true,
|
||||
blocking: false
|
||||
});
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
this.preparedSentenceCache.set(nextCacheKey, prepared);
|
||||
return prepared;
|
||||
})()
|
||||
.then(() => {
|
||||
.then((prepared) => {
|
||||
if (queueGeneration !== this.queueGeneration) return false;
|
||||
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
|
||||
return true;
|
||||
return prepared || true;
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('SentenceQueue: Prefetch failed:', err);
|
||||
@@ -929,13 +1069,6 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.prefetchingSpeech.set(nextCacheKey, promise);
|
||||
started += 1;
|
||||
|
||||
if (this.isSpeechItem(nextItem)) {
|
||||
spokenPrepared += 1;
|
||||
}
|
||||
|
||||
if (spokenPrepared >= 1 && started >= 2) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (started === 0) {
|
||||
@@ -1341,6 +1474,9 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.cancelGenerationRequests('sentence-queue-cleared');
|
||||
this.cancelAssetPreloads('sentence-queue-cleared');
|
||||
this.prefetchingSpeech.clear();
|
||||
this.prefetchingWebGLBook.clear();
|
||||
this.preparedSentenceCache.clear();
|
||||
this.webglBookPrepareChain = Promise.resolve();
|
||||
this.pauseBeforeNextReason = null;
|
||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||
detail: { reason: 'sentence-queue-cleared' }
|
||||
|
||||
@@ -157,6 +157,13 @@ class StoryHistoryModule extends BaseModule {
|
||||
...record,
|
||||
lineStart,
|
||||
lineCount,
|
||||
...(Number.isFinite(Number(metrics.pageStart)) ? { pageStart: Math.max(0, Number(metrics.pageStart)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.pageEnd)) ? { pageEnd: Math.max(0, Number(metrics.pageEnd)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.pageLineStart)) ? { pageLineStart: Math.max(0, Number(metrics.pageLineStart)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.pageLineEnd)) ? { pageLineEnd: Math.max(0, Number(metrics.pageLineEnd)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.spreadStart)) ? { spreadStart: Math.max(0, Number(metrics.spreadStart)) } : {}),
|
||||
...(Number.isFinite(Number(metrics.spreadEnd)) ? { spreadEnd: Math.max(0, Number(metrics.spreadEnd)) } : {}),
|
||||
...(metrics.pagination ? { pagination: metrics.pagination } : {}),
|
||||
metricsUpdatedAt: Date.now()
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class TextProcessorModule extends BaseModule {
|
||||
'hyphenate',
|
||||
'setLocale',
|
||||
'loadHyphenopolyLoader',
|
||||
'ensureHyphenopolySeedElements',
|
||||
'normalizeHyphenationLocale',
|
||||
'applyLocaleTypography',
|
||||
'getTypographyLocale',
|
||||
@@ -162,6 +163,7 @@ class TextProcessorModule extends BaseModule {
|
||||
this.hyphenatorReady = false;
|
||||
|
||||
await this.loadHyphenopolyLoader();
|
||||
this.ensureHyphenopolySeedElements(locale);
|
||||
|
||||
window.Hyphenopoly.config({
|
||||
require: {
|
||||
@@ -203,6 +205,35 @@ class TextProcessorModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
ensureHyphenopolySeedElements(locale = 'en-us') {
|
||||
const normalizedLocale = this.normalizeHyphenationLocale(locale);
|
||||
let container = document.getElementById('hyphenopoly_seed_elements');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'hyphenopoly_seed_elements';
|
||||
container.setAttribute('aria-hidden', 'true');
|
||||
Object.assign(container.style, {
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
overflow: 'hidden',
|
||||
opacity: '0',
|
||||
pointerEvents: 'none',
|
||||
left: '-9999px',
|
||||
top: '-9999px'
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
container.innerHTML = '';
|
||||
['hyphenate', 'hyphenatePipe'].forEach((className) => {
|
||||
const seed = document.createElement('span');
|
||||
seed.className = className;
|
||||
seed.lang = normalizedLocale;
|
||||
seed.textContent = normalizedLocale.startsWith('de') ? 'Silbentrennung' : 'hyphenation';
|
||||
container.appendChild(seed);
|
||||
});
|
||||
}
|
||||
|
||||
loadHyphenopolyLoader() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.Hyphenopoly && typeof window.Hyphenopoly.config === 'function') {
|
||||
|
||||
@@ -29,6 +29,7 @@ class UIControllerModule extends BaseModule {
|
||||
this.ttsHandler = null;
|
||||
this.socketClient = null;
|
||||
this.animationQueue = null;
|
||||
this.currentInputMode = document.documentElement.dataset.inputMode || 'none';
|
||||
|
||||
// Add TTS toggle state
|
||||
this.ttsEnabled = false;
|
||||
@@ -56,6 +57,7 @@ class UIControllerModule extends BaseModule {
|
||||
'clearDisplay',
|
||||
'sendCommand',
|
||||
'isInteractiveClickTarget',
|
||||
'isChoiceAwaitingPlayer',
|
||||
'updateButtonStates'
|
||||
]);
|
||||
}
|
||||
@@ -262,6 +264,9 @@ class UIControllerModule extends BaseModule {
|
||||
if (!event.detail || event.detail.moduleId === this.id) return;
|
||||
this.handleCommand(event.detail);
|
||||
});
|
||||
this.addEventListener(document, 'story:input-mode', (event) => {
|
||||
this.currentInputMode = ['text', 'choice', 'end', 'none'].includes(event.detail) ? event.detail : 'none';
|
||||
});
|
||||
|
||||
this.addEventListener(document, 'click', (event) => {
|
||||
if (this.isInteractiveClickTarget(event.target)) {
|
||||
@@ -270,7 +275,7 @@ class UIControllerModule extends BaseModule {
|
||||
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true';
|
||||
if ((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) {
|
||||
if (((playbackCoordinator && playbackCoordinator.isPlaying) || hasSkippablePause) && !this.isChoiceAwaitingPlayer()) {
|
||||
this.handleCommand({ type: 'continue', source: 'book-click' });
|
||||
}
|
||||
|
||||
@@ -668,6 +673,14 @@ class UIControllerModule extends BaseModule {
|
||||
].join(',')));
|
||||
}
|
||||
|
||||
isChoiceAwaitingPlayer() {
|
||||
if (this.currentInputMode !== 'choice') {
|
||||
return false;
|
||||
}
|
||||
const choicePanel = document.getElementById('story_choices');
|
||||
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
|
||||
}
|
||||
|
||||
handleCommand(command) {
|
||||
// Route commands to appropriate handlers
|
||||
switch (command.type) {
|
||||
@@ -679,6 +692,9 @@ class UIControllerModule extends BaseModule {
|
||||
break;
|
||||
case 'continue':
|
||||
{
|
||||
if (this.isChoiceAwaitingPlayer()) {
|
||||
return;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('ui:command', {
|
||||
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
|
||||
}));
|
||||
|
||||
@@ -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', 'book-playback-timeline', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer'];
|
||||
|
||||
// DOM elements
|
||||
this.container = null;
|
||||
@@ -68,6 +68,9 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
'applyGameConfig',
|
||||
'applyTranslations',
|
||||
'renderSentence',
|
||||
'isWebGLMode',
|
||||
'playWebGLBookSentence',
|
||||
'prepareWebGLBookReveal',
|
||||
'renderStoryBlock',
|
||||
'prepareRenderableBlock',
|
||||
'prepareTextRenderable',
|
||||
@@ -170,6 +173,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 +359,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 +532,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);
|
||||
@@ -972,8 +981,18 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const generation = this.displayGeneration;
|
||||
const sentenceGameId = sentence.gameId || null;
|
||||
const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId);
|
||||
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
|
||||
|
||||
try {
|
||||
if (useWebGLBookReveal) {
|
||||
await this.playWebGLBookSentence(sentence);
|
||||
if (!isCurrent()) return null;
|
||||
if (sentence.blockId != null) this.markBlockRendered(sentence.blockId);
|
||||
this.dispatchDeferredTagsForBlock(sentence);
|
||||
if (sentence.onComplete) sentence.onComplete();
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.ensureLiveTailWindow();
|
||||
if (!isCurrent()) return null;
|
||||
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
|
||||
@@ -985,7 +1004,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
playback: true,
|
||||
placement: 'append',
|
||||
token: this.renderWindowToken,
|
||||
generation
|
||||
generation,
|
||||
deferRenderedMark: useWebGLBookReveal
|
||||
});
|
||||
if (!element) return null;
|
||||
if (!isCurrent()) {
|
||||
@@ -1002,7 +1022,14 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
if (sentence.kind === 'image') {
|
||||
this.revealImageBlock(element);
|
||||
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
||||
await this.playbackCoordinator.play(sentence);
|
||||
if (useWebGLBookReveal) {
|
||||
await this.playWebGLBookSentence(sentence);
|
||||
} else {
|
||||
await this.playbackCoordinator.play(sentence);
|
||||
}
|
||||
if (useWebGLBookReveal && sentence.blockId != null) {
|
||||
this.markBlockRendered(sentence.blockId);
|
||||
}
|
||||
} else if (sentence.kind === 'music') {
|
||||
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
|
||||
}
|
||||
@@ -1022,6 +1049,27 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
isWebGLMode() {
|
||||
return document.body?.dataset?.webglUiMode === '3d'
|
||||
|| document.body?.classList?.contains('webgl-mode');
|
||||
}
|
||||
|
||||
async playWebGLBookSentence(sentence) {
|
||||
const timeline = this.getModule('book-playback-timeline');
|
||||
if (!timeline || typeof timeline.playSentence !== 'function') {
|
||||
throw new Error('WebGL book playback timeline is required for 3D sentence playback');
|
||||
}
|
||||
return timeline.playSentence(sentence);
|
||||
}
|
||||
|
||||
async prepareWebGLBookReveal(sentence) {
|
||||
const timeline = this.getModule('book-playback-timeline');
|
||||
if (!timeline || typeof timeline.prepareSentence !== 'function') {
|
||||
throw new Error('WebGL book playback timeline is required for 3D reveal preparation');
|
||||
}
|
||||
return timeline.prepareSentence(sentence, { immediate: true });
|
||||
}
|
||||
|
||||
async rerenderStory() {
|
||||
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
|
||||
console.log('UIDisplayHandler: Re-typesetting story after page resize');
|
||||
@@ -1091,7 +1139,8 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
renderedItemsTarget = this.renderedItems,
|
||||
token = null,
|
||||
recordMetrics = true,
|
||||
generation = this.displayGeneration
|
||||
generation = this.displayGeneration,
|
||||
deferRenderedMark = false
|
||||
} = options;
|
||||
if (!item || !this.paragraphContainer) return null;
|
||||
const renderable = await this.prepareRenderableBlock(item);
|
||||
@@ -1138,7 +1187,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
if (item.blockId != null) {
|
||||
element.dataset.storyBlockId = String(item.blockId);
|
||||
this.markBlockRendered(item.blockId);
|
||||
if (!deferRenderedMark) this.markBlockRendered(item.blockId);
|
||||
}
|
||||
element.dataset.lineStart = String(renderable.lineStart);
|
||||
element.dataset.lineCount = String(renderable.lineCount);
|
||||
@@ -1755,7 +1804,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
}
|
||||
|
||||
getLatestHistoryBlockId() {
|
||||
return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0));
|
||||
return Math.max(0, Number((this.storyHistory?.nextBlockId || 1) - 1));
|
||||
}
|
||||
|
||||
updateStoryScrollbar(detail = {}) {
|
||||
@@ -2394,8 +2443,15 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
|
||||
? 'landscape'
|
||||
: String(metadata.size || 'landscape').toLowerCase();
|
||||
const aspect = normalizedSize === 'portrait' ? (9 / 16) : normalizedSize === 'square' ? 1 : (16 / 9);
|
||||
const aspect = normalizedSize === 'portrait'
|
||||
? (9 / 16)
|
||||
: normalizedSize === 'square'
|
||||
? 1
|
||||
: normalizedSize === 'full'
|
||||
? (4.25 / 6.875)
|
||||
: (16 / 9);
|
||||
const isPortrait = normalizedSize === 'portrait';
|
||||
const isFullPage = normalizedSize === 'full';
|
||||
const imageGap = lineHeight;
|
||||
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
|
||||
const maxImageWidth = isPortrait
|
||||
@@ -2404,7 +2460,7 @@ class UIDisplayHandlerModule extends BaseModule {
|
||||
const naturalHeight = maxImageWidth / aspect;
|
||||
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||
const verticalMargin = lineHeight / 2;
|
||||
const lineCount = imageLineCount + 1;
|
||||
const lineCount = isFullPage ? this.pageLineCount : imageLineCount + 1;
|
||||
const height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
|
||||
const width = Math.min(maxImageWidth, height * aspect);
|
||||
|
||||
|
||||
@@ -308,6 +308,18 @@ class UIInputHandlerModule extends BaseModule {
|
||||
normalizeProcessState(state) {
|
||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
|
||||
// The player is in control when an input prompt is open AND the book is not actively
|
||||
// playing a sentence (the timeline owns webglBookPlaybackActive). Then the cursor must
|
||||
// show the input/server state, never the playback feather — even if a stale playing-*
|
||||
// state lingers — so strip the playback overlay. While a sentence is actually playing
|
||||
// the feather wins, even if an input mode is still set from the previous turn.
|
||||
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||
const awaitingPlayer = !playbackActive && ['choice', 'text', 'end'].includes(this.inputMode);
|
||||
if (awaitingPlayer) {
|
||||
if (state === 'playing-ready') return 'ready';
|
||||
if (state === 'playing-generating') return 'waiting-generating';
|
||||
return state;
|
||||
}
|
||||
|
||||
if (isPlaying && state === 'ready') {
|
||||
return 'playing-ready';
|
||||
@@ -345,6 +357,12 @@ class UIInputHandlerModule extends BaseModule {
|
||||
this.setInputModeDataset();
|
||||
const state = document.documentElement.dataset.processState || 'loading';
|
||||
this.setInputAvailability(this.inputMode === 'text' && state === 'ready');
|
||||
// Opening an input-awaiting prompt hands control to the player; reflect that in the
|
||||
// cursor immediately instead of leaving the prior playback state showing (the live
|
||||
// flow does not always dispatch a fresh process-state when the prompt appears).
|
||||
if (this.inputMode !== 'none') {
|
||||
this.setProcessState('ready', { reason: `input-mode:${this.inputMode}` });
|
||||
}
|
||||
}
|
||||
|
||||
setInputModeDataset() {
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* 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;
|
||||
const DEFAULT_PAGE_RESERVE = 50;
|
||||
|
||||
class WebGLBookSceneModule extends BaseModule {
|
||||
constructor() {
|
||||
super('webgl-book-scene', 'WebGL Book Scene');
|
||||
this.dependencies = ['persistence-manager', 'localization', 'game-config', 'book-pagination', 'book-texture-renderer'];
|
||||
this.persistenceManager = null;
|
||||
this.localization = null;
|
||||
this.gameConfig = null;
|
||||
this.mode = '2d';
|
||||
this.is3dSupported = false;
|
||||
// Production control surface + visible-spread accessor for the dynamically
|
||||
// imported webgl-book-lab. Populated by the lab once the scene is built.
|
||||
this.sceneControl = null;
|
||||
this.getVisibleSpreadIndex = null;
|
||||
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',
|
||||
'getMetadataNumber',
|
||||
'getFixedBookPageCount',
|
||||
'getFixedPageReserve',
|
||||
'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.gameConfig = this.getModule('game-config');
|
||||
|
||||
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 fixedPageCount = this.getFixedBookPageCount();
|
||||
const fixedPageReserve = this.getFixedPageReserve();
|
||||
const scenePrefs = this.persistenceManager.getPreference('webgl', 'bookPageCount', null);
|
||||
if (Number.isFinite(fixedPageCount)) {
|
||||
this.persistenceManager.updatePreference('webgl', 'bookPageCount', fixedPageCount);
|
||||
} else 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);
|
||||
}
|
||||
const pageReserve = this.persistenceManager.getPreference('webgl', 'pageReserve', null);
|
||||
if (Number.isFinite(fixedPageReserve)) {
|
||||
this.persistenceManager.updatePreference('webgl', 'pageReserve', fixedPageReserve);
|
||||
} else if (!Number.isFinite(Number(pageReserve))) {
|
||||
this.persistenceManager.updatePreference('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE);
|
||||
}
|
||||
}
|
||||
|
||||
getMetadataNumber(keys = []) {
|
||||
const metadata = this.gameConfig?.getMetadata?.() || {};
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, key)) continue;
|
||||
const value = Number(metadata[key]);
|
||||
if (Number.isFinite(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getFixedBookPageCount() {
|
||||
return this.getMetadataNumber(['bookPageCount', 'defaultBookPageCount', 'webglBookPageCount']);
|
||||
}
|
||||
|
||||
getFixedPageReserve() {
|
||||
return this.getMetadataNumber(['pageReserve', 'defaultPageReserve', 'webglPageReserve']);
|
||||
}
|
||||
|
||||
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;
|
||||
const pageReserve = this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE;
|
||||
window.WebGLBookInitialState = {
|
||||
appMode: true,
|
||||
pageCount,
|
||||
progress,
|
||||
pageReserve,
|
||||
fixedPageCount: this.getFixedBookPageCount(),
|
||||
fixedPageReserve: this.getFixedPageReserve(),
|
||||
t: (key, params = {}) => this.t(key, params),
|
||||
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(44rem, 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',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
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;
|
||||
},
|
||||
updatePageReserve: (value) => {
|
||||
if (this.preferenceWriteGuard) return;
|
||||
this.preferenceWriteGuard = true;
|
||||
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value);
|
||||
this.preferenceWriteGuard = false;
|
||||
},
|
||||
getBookState: () => this.sceneControl?.getBookState?.() || {
|
||||
pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT,
|
||||
pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE,
|
||||
progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS
|
||||
},
|
||||
applyBookState: (state = {}) => {
|
||||
const pageCount = Number(state.pageCount);
|
||||
const pageReserve = Number(state.pageReserve);
|
||||
const progress = Number(state.progress);
|
||||
if (Number.isFinite(pageCount)) {
|
||||
this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount);
|
||||
this.sceneControl?.setBookPageCount?.(pageCount);
|
||||
}
|
||||
if (Number.isFinite(pageReserve)) {
|
||||
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve);
|
||||
this.sceneControl?.setPageReserve?.(pageReserve);
|
||||
}
|
||||
if (Number.isFinite(progress)) {
|
||||
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
|
||||
this.sceneControl?.setReadingProgress?.(progress);
|
||||
}
|
||||
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
|
||||
if (Number.isFinite(maxVisitedPagePosition)) {
|
||||
this.sceneControl?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async initializeScene() {
|
||||
if (this.labImportPromise) return this.labImportPromise;
|
||||
const moduleVersion = window.MODULE_CACHE_BUSTER || 'dev';
|
||||
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(moduleVersion)}`);
|
||||
await this.labImportPromise;
|
||||
this.reportProgress(94, 'Uploading initial book page textures');
|
||||
const pagination = this.getModule('book-pagination');
|
||||
const initialSpread = pagination?.getCurrentSpread?.();
|
||||
if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') {
|
||||
await window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true });
|
||||
} else {
|
||||
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 = this.sceneControl?.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) {
|
||||
this.sceneControl?.setReadingProgress?.(value);
|
||||
} else if (key === 'bookPageCount' && !this.preferenceWriteGuard) {
|
||||
this.sceneControl?.setBookPageCount?.(value);
|
||||
} else if (key === 'pageReserve' && !this.preferenceWriteGuard) {
|
||||
this.sceneControl?.setPageReserve?.(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(() => {
|
||||
this.sceneControl?.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;
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* WebGL Page Cache Module
|
||||
* Persists fully typeset book page canvases in IndexedDB for fast VRAM prewarm.
|
||||
*/
|
||||
import { BaseModule } from './base-module.js';
|
||||
|
||||
class WebGLPageCacheModule extends BaseModule {
|
||||
constructor() {
|
||||
super('webgl-page-cache', 'WebGL Page Cache');
|
||||
|
||||
this.dependencies = [];
|
||||
this.dbName = 'webglPageTextureCacheDB';
|
||||
this.dbVersion = 1;
|
||||
this.storeName = 'webglPageTextureStore';
|
||||
this.db = null;
|
||||
this.cacheStatus = 'uninitialized';
|
||||
this.currentCacheSize = 0;
|
||||
this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024;
|
||||
this.memoryCanvasCache = new Map();
|
||||
this.maxMemoryCanvasCount = 256;
|
||||
this.textureRuntime = null;
|
||||
this.residentTextures = new Map();
|
||||
this.maxResidentTextureCount = 192;
|
||||
this.preparedTextures = {
|
||||
left: new Map(),
|
||||
right: new Map()
|
||||
};
|
||||
this.preparedRevealPlans = new Map();
|
||||
this.visibleTextures = {
|
||||
left: null,
|
||||
right: null
|
||||
};
|
||||
this.visibleFallbackCanvases = {
|
||||
left: null,
|
||||
right: null
|
||||
};
|
||||
this.maxPreparedTextureCount = 128;
|
||||
this.blankTexture = null;
|
||||
this.problemLog = [];
|
||||
this.pendingPageWrites = new Map();
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'openDB',
|
||||
'configureTextureRuntime',
|
||||
'cachePageCanvas',
|
||||
'getPageCanvas',
|
||||
'putPageCanvas',
|
||||
'storePageCanvas',
|
||||
'preparePageTexture',
|
||||
'takePreparedPageTexture',
|
||||
'rememberPreparedRevealPlan',
|
||||
'takePreparedRevealPlan',
|
||||
'hasPreparedRevealPlan',
|
||||
'registerVisibleTexture',
|
||||
'bindVisibleTextureSource',
|
||||
'getVisibleTexture',
|
||||
'rememberResidentTexture',
|
||||
'getResidentTexture',
|
||||
'getResidentTextureForMeta',
|
||||
'ensurePageTexture',
|
||||
'prewarmPageTexture',
|
||||
'prewarmSpreadTextures',
|
||||
'prewarmNavigationWindow',
|
||||
'getBlankTexture',
|
||||
'createTextureFromCanvas',
|
||||
'disposeTextureRecord',
|
||||
'makePageKey',
|
||||
'getPageWriteKey',
|
||||
'makeResidentKey',
|
||||
'cloneCanvas',
|
||||
'canvasToBlob',
|
||||
'blobToCanvas',
|
||||
'isOlderPageEntry',
|
||||
'isOlderPageMeta',
|
||||
'recordProblem',
|
||||
'getRuntimeState',
|
||||
'manageCacheSize',
|
||||
'calculateTotalCacheSize',
|
||||
'deleteEntry',
|
||||
'rememberCanvas',
|
||||
'tx'
|
||||
]);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.reportProgress(20, 'Opening WebGL page texture cache');
|
||||
try {
|
||||
await this.openDB();
|
||||
this.reportProgress(70, 'Measuring WebGL page texture cache');
|
||||
this.currentCacheSize = await this.calculateTotalCacheSize();
|
||||
this.cacheStatus = 'ready';
|
||||
this.reportProgress(100, 'WebGL page texture cache ready');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('WebGLPageCache: IndexedDB unavailable; persistent page caching is in a problem state', error);
|
||||
this.cacheStatus = 'error';
|
||||
this.reportProgress(100, 'WebGL page texture cache unavailable');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
configureTextureRuntime({
|
||||
THREE = null,
|
||||
renderer = null,
|
||||
configureTexture = null,
|
||||
createBlankCanvas = null,
|
||||
maxResidentTextureCount = this.maxResidentTextureCount,
|
||||
maxPreparedTextureCount = this.maxPreparedTextureCount
|
||||
} = {}) {
|
||||
this.textureRuntime = {
|
||||
THREE,
|
||||
renderer,
|
||||
configureTexture,
|
||||
createBlankCanvas
|
||||
};
|
||||
this.maxResidentTextureCount = Math.max(1, Math.round(Number(maxResidentTextureCount || this.maxResidentTextureCount)));
|
||||
this.maxPreparedTextureCount = Math.max(1, Math.round(Number(maxPreparedTextureCount || this.maxPreparedTextureCount)));
|
||||
return this.getRuntimeState();
|
||||
}
|
||||
|
||||
openDB() {
|
||||
if (this.db) return Promise.resolve(this.db);
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.dbVersion);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => reject(new Error('WebGL page texture cache upgrade blocked'));
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
this.db.onversionchange = () => {
|
||||
this.db?.close?.();
|
||||
this.db = null;
|
||||
this.cacheStatus = 'uninitialized';
|
||||
};
|
||||
resolve(this.db);
|
||||
};
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(this.storeName)) {
|
||||
const pageStore = db.createObjectStore(this.storeName, { keyPath: 'key' });
|
||||
pageStore.createIndex('lastAccessed', 'lastAccessed', { unique: false });
|
||||
pageStore.createIndex('size', 'size', { unique: false });
|
||||
pageStore.createIndex('pageIndex', 'pageIndex', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
tx(mode = 'readonly') {
|
||||
return this.db.transaction([this.storeName], mode).objectStore(this.storeName);
|
||||
}
|
||||
|
||||
makePageKey({ pageIndex, width, height, kind = 'content', section = 'body', cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
|
||||
const safePage = Math.max(0, Math.round(Number(pageIndex || 0)));
|
||||
const safeWidth = Math.max(1, Math.round(Number(width || 0)));
|
||||
const safeHeight = Math.max(1, Math.round(Number(height || 0)));
|
||||
const safeKind = String(kind || 'content').replace(/[^a-z0-9_-]/gi, '');
|
||||
const safeSection = String(section || 'body').replace(/[^a-z0-9_-]/gi, '');
|
||||
return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`;
|
||||
}
|
||||
|
||||
getPageWriteKey(pageMeta = {}, canvas = null) {
|
||||
return this.makePageKey({
|
||||
...pageMeta,
|
||||
width: canvas?.width ?? pageMeta.width,
|
||||
height: canvas?.height ?? pageMeta.height
|
||||
});
|
||||
}
|
||||
|
||||
makeResidentKey(pageMetaOrIndex = {}) {
|
||||
const pageMeta = typeof pageMetaOrIndex === 'number'
|
||||
? { pageIndex: pageMetaOrIndex }
|
||||
: pageMetaOrIndex || {};
|
||||
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
|
||||
const kind = String(pageMeta.kind || 'content').replace(/[^a-z0-9_-]/gi, '');
|
||||
const section = String(pageMeta.section || 'body').replace(/[^a-z0-9_-]/gi, '');
|
||||
return `${pageIndex}:${kind}:${section}`;
|
||||
}
|
||||
|
||||
createTextureFromCanvas(canvas = null) {
|
||||
const runtime = this.textureRuntime || {};
|
||||
if (!canvas || !runtime.THREE?.CanvasTexture) return null;
|
||||
const texture = new runtime.THREE.CanvasTexture(canvas);
|
||||
if (typeof runtime.configureTexture === 'function') runtime.configureTexture(texture);
|
||||
texture.needsUpdate = true;
|
||||
if (typeof runtime.renderer?.initTexture === 'function') {
|
||||
runtime.renderer.initTexture(texture);
|
||||
texture.needsUpdate = false;
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
getBlankTexture() {
|
||||
if (this.blankTexture) return this.blankTexture;
|
||||
const canvas = this.textureRuntime?.createBlankCanvas?.();
|
||||
this.blankTexture = this.createTextureFromCanvas(canvas);
|
||||
return this.blankTexture;
|
||||
}
|
||||
|
||||
async putPageCanvas(pageMeta = {}, canvas = null, options = {}) {
|
||||
const texture = options.resident === false
|
||||
? null
|
||||
: this.rememberResidentTexture(pageMeta, this.createTextureFromCanvas(canvas), canvas, true);
|
||||
if (options.persist !== false) {
|
||||
const stored = await this.cachePageCanvas(pageMeta, canvas);
|
||||
return texture || stored;
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
storePageCanvas(pageMeta = {}, canvas = null, options = {}) {
|
||||
if (!canvas || !pageMeta || !Number.isFinite(Number(pageMeta.pageIndex))) return Promise.resolve(false);
|
||||
const frozenCanvas = this.cloneCanvas(canvas);
|
||||
const key = this.getPageWriteKey(pageMeta, frozenCanvas);
|
||||
const pending = this.pendingPageWrites.get(key);
|
||||
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
|
||||
const previousWrite = pending?.promise || Promise.resolve();
|
||||
const write = previousWrite.catch(() => false)
|
||||
.then(() => this.putPageCanvas(pageMeta, frozenCanvas, {
|
||||
persist: options.persist !== false,
|
||||
resident: options.resident !== false
|
||||
}))
|
||||
.then((stored) => {
|
||||
if (!stored) {
|
||||
this.recordProblem({
|
||||
type: 'db-write-failed',
|
||||
pageIndex: pageMeta?.pageIndex ?? null,
|
||||
key
|
||||
});
|
||||
}
|
||||
return stored;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.recordProblem({
|
||||
type: 'db-write-error',
|
||||
pageIndex: pageMeta?.pageIndex ?? null,
|
||||
key,
|
||||
message: error?.message || String(error)
|
||||
});
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pendingPageWrites.get(key)?.promise === write) {
|
||||
this.pendingPageWrites.delete(key);
|
||||
}
|
||||
});
|
||||
this.pendingPageWrites.set(key, {
|
||||
promise: write,
|
||||
pageMeta: { ...(pageMeta || {}) }
|
||||
});
|
||||
return write;
|
||||
}
|
||||
|
||||
cloneCanvas(canvas = null) {
|
||||
if (!canvas) return null;
|
||||
const clone = document.createElement('canvas');
|
||||
clone.width = canvas.width;
|
||||
clone.height = canvas.height;
|
||||
const context = clone.getContext('2d');
|
||||
if (context) context.drawImage(canvas, 0, 0);
|
||||
return clone;
|
||||
}
|
||||
|
||||
preparePageTexture(side = 'left', key = '', pageMeta = {}, canvas = null, revealDetail = {}) {
|
||||
if (!canvas || !key) return null;
|
||||
const normalizedSide = side === 'right' ? 'right' : 'left';
|
||||
const texture = this.createTextureFromCanvas(canvas);
|
||||
const baseTexture = revealDetail?.baseCanvas ? this.createTextureFromCanvas(revealDetail.baseCanvas) : null;
|
||||
this.preparedTextures[normalizedSide].set(key, {
|
||||
texture,
|
||||
baseTexture,
|
||||
sourceCanvas: canvas,
|
||||
revealDetail,
|
||||
pageMeta: { ...(pageMeta || {}) },
|
||||
uploadedAt: performance.now()
|
||||
});
|
||||
this.rememberResidentTexture(pageMeta, texture, canvas, false);
|
||||
while (this.preparedTextures[normalizedSide].size > this.maxPreparedTextureCount) {
|
||||
const oldestKey = this.preparedTextures[normalizedSide].keys().next().value;
|
||||
const oldest = this.preparedTextures[normalizedSide].get(oldestKey);
|
||||
this.disposeTextureRecord(oldest);
|
||||
this.preparedTextures[normalizedSide].delete(oldestKey);
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
takePreparedPageTexture(side = 'left', key = '') {
|
||||
const normalizedSide = side === 'right' ? 'right' : 'left';
|
||||
const prepared = this.preparedTextures[normalizedSide].get(key);
|
||||
if (!prepared) return null;
|
||||
this.preparedTextures[normalizedSide].delete(key);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
rememberPreparedRevealPlan(blockId = '', prepared = null) {
|
||||
const id = String(blockId ?? '');
|
||||
if (!id || !prepared) return null;
|
||||
this.preparedRevealPlans.set(id, {
|
||||
...prepared,
|
||||
storedAt: performance.now()
|
||||
});
|
||||
while (this.preparedRevealPlans.size > this.maxPreparedTextureCount) {
|
||||
const oldestKey = this.preparedRevealPlans.keys().next().value;
|
||||
this.preparedRevealPlans.delete(oldestKey);
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
takePreparedRevealPlan(blockId = '') {
|
||||
const id = String(blockId ?? '');
|
||||
const prepared = this.preparedRevealPlans.get(id);
|
||||
if (!prepared) return null;
|
||||
this.preparedRevealPlans.delete(id);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
hasPreparedRevealPlan(blockId = '') {
|
||||
const id = String(blockId ?? '');
|
||||
return Boolean(id && this.preparedRevealPlans.has(id));
|
||||
}
|
||||
|
||||
registerVisibleTexture(side = 'left', texture = null, fallbackCanvas = null) {
|
||||
const normalizedSide = side === 'right' ? 'right' : 'left';
|
||||
this.visibleTextures[normalizedSide] = texture || null;
|
||||
this.visibleFallbackCanvases[normalizedSide] = fallbackCanvas || null;
|
||||
return texture || null;
|
||||
}
|
||||
|
||||
bindVisibleTextureSource(side = 'left', sourceCanvas = null) {
|
||||
const normalizedSide = side === 'right' ? 'right' : 'left';
|
||||
const texture = this.visibleTextures[normalizedSide];
|
||||
const canvas = sourceCanvas || this.visibleFallbackCanvases[normalizedSide] || null;
|
||||
if (!texture || !canvas) return null;
|
||||
texture.image = canvas;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
getVisibleTexture(side = 'left') {
|
||||
return this.visibleTextures[side === 'right' ? 'right' : 'left'] || null;
|
||||
}
|
||||
|
||||
rememberResidentTexture(pageMeta = {}, texture = null, sourceCanvas = null, ownsTexture = true) {
|
||||
const pageIndex = Number(pageMeta?.pageIndex);
|
||||
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
|
||||
const key = this.makeResidentKey(pageMeta);
|
||||
const existing = this.residentTextures.get(key);
|
||||
if (this.isOlderPageMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
|
||||
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
|
||||
this.residentTextures.set(key, {
|
||||
texture,
|
||||
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
|
||||
lastUsedAt: performance.now(),
|
||||
ownsTexture,
|
||||
pageMeta: {
|
||||
...(existing?.pageMeta || {}),
|
||||
...(pageMeta || {})
|
||||
}
|
||||
});
|
||||
while (this.residentTextures.size > this.maxResidentTextureCount) {
|
||||
const oldestKey = this.residentTextures.keys().next().value;
|
||||
const oldest = this.residentTextures.get(oldestKey);
|
||||
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
|
||||
this.residentTextures.delete(oldestKey);
|
||||
}
|
||||
return texture;
|
||||
}
|
||||
|
||||
getResidentTexture(pageMetaOrIndex = {}) {
|
||||
const key = this.makeResidentKey(pageMetaOrIndex);
|
||||
const resident = this.residentTextures.get(key);
|
||||
if (!resident) return null;
|
||||
resident.lastUsedAt = performance.now();
|
||||
this.residentTextures.delete(key);
|
||||
this.residentTextures.set(key, resident);
|
||||
return resident.texture || null;
|
||||
}
|
||||
|
||||
getResidentTextureForMeta(pageMeta = {}) {
|
||||
const pageIndex = Number(pageMeta?.pageIndex);
|
||||
if (!Number.isFinite(pageIndex)) return null;
|
||||
const key = this.makeResidentKey(pageMeta);
|
||||
const resident = this.residentTextures.get(key);
|
||||
if (!resident) return null;
|
||||
return this.getResidentTexture(pageMeta);
|
||||
}
|
||||
|
||||
async ensurePageTexture(pageMeta = {}, options = {}) {
|
||||
if (pageMeta?.kind === 'blank') {
|
||||
return this.rememberResidentTexture(pageMeta, this.getBlankTexture(), null, false);
|
||||
}
|
||||
const resident = this.getResidentTextureForMeta(pageMeta);
|
||||
if (resident) return resident;
|
||||
if (options.canvas) return this.putPageCanvas(pageMeta, options.canvas, {
|
||||
persist: options.persist !== false,
|
||||
resident: true
|
||||
});
|
||||
const sourceCanvas = await this.getPageCanvas(pageMeta);
|
||||
if (!sourceCanvas) {
|
||||
if (options.recordMiss !== false) {
|
||||
this.recordProblem({
|
||||
type: 'db-cache-miss',
|
||||
pageIndex: pageMeta?.pageIndex ?? null,
|
||||
width: pageMeta?.width ?? null,
|
||||
height: pageMeta?.height ?? null
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const cachedMeta = sourceCanvas.__webglPageCacheMeta || pageMeta;
|
||||
return this.rememberResidentTexture(cachedMeta, this.createTextureFromCanvas(sourceCanvas), sourceCanvas, true);
|
||||
}
|
||||
|
||||
async prewarmPageTexture(pageMeta = {}, options = {}) {
|
||||
return this.ensurePageTexture(pageMeta, {
|
||||
recordMiss: options.recordMiss !== false && pageMeta?.kind !== 'blank'
|
||||
});
|
||||
}
|
||||
|
||||
async prewarmSpreadTextures(spreadIndex = 0, getPageMetaForIndex = null, options = {}) {
|
||||
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
||||
const leftIndex = spread * 2;
|
||||
const rightIndex = leftIndex + 1;
|
||||
const leftMeta = getPageMetaForIndex?.(leftIndex) || { pageIndex: leftIndex, kind: 'blank', section: leftIndex < 3 ? 'frontmatter' : 'body' };
|
||||
const rightMeta = getPageMetaForIndex?.(rightIndex) || { pageIndex: rightIndex, kind: 'blank', section: rightIndex < 3 ? 'frontmatter' : 'body' };
|
||||
const [left, right] = await Promise.all([
|
||||
this.prewarmPageTexture(leftMeta, options),
|
||||
this.prewarmPageTexture(rightMeta, options)
|
||||
]);
|
||||
return { spreadIndex: spread, left, right };
|
||||
}
|
||||
|
||||
async prewarmNavigationWindow({
|
||||
currentSpread = 0,
|
||||
targetSpread = null,
|
||||
endSpread = 0,
|
||||
getPageMetaForIndex = null,
|
||||
recordMiss = true
|
||||
} = {}) {
|
||||
const current = Math.max(0, Math.round(Number(currentSpread || 0)));
|
||||
const end = Math.max(0, Math.round(Number(endSpread || 0)));
|
||||
const spreads = new Set([0, end, current, Math.max(0, current - 1), current + 1]);
|
||||
const explicitTarget = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : null;
|
||||
if (explicitTarget !== null) spreads.add(explicitTarget);
|
||||
const upperBound = Math.max(end, current + 1, explicitTarget ?? 0);
|
||||
const bounded = Array.from(spreads).filter(value => value >= 0 && value <= upperBound);
|
||||
const results = await Promise.all(bounded.map(spread => this.prewarmSpreadTextures(spread, getPageMetaForIndex, { recordMiss })));
|
||||
return results.reduce((map, spread) => {
|
||||
map[spread.spreadIndex] = spread;
|
||||
return map;
|
||||
}, {});
|
||||
}
|
||||
|
||||
disposeTextureRecord(record = null) {
|
||||
record?.texture?.dispose?.();
|
||||
record?.baseTexture?.dispose?.();
|
||||
}
|
||||
|
||||
async cachePageCanvas(pageMeta = {}, canvas = null) {
|
||||
if (!canvas || !this.db || this.cacheStatus !== 'ready') return false;
|
||||
const pageIndex = Number(pageMeta.pageIndex);
|
||||
if (!Number.isFinite(pageIndex) || pageIndex < 0) return false;
|
||||
const key = this.makePageKey({
|
||||
pageIndex,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
kind: pageMeta.kind,
|
||||
section: pageMeta.section,
|
||||
cacheKey: pageMeta.cacheKey
|
||||
});
|
||||
try {
|
||||
const blob = await this.canvasToBlob(canvas);
|
||||
if (!blob) return false;
|
||||
const oldEntry = await new Promise((resolve, reject) => {
|
||||
const request = this.tx('readonly').get(key);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (this.isOlderPageEntry(pageMeta, oldEntry)) return true;
|
||||
await this.manageCacheSize(blob.size);
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = this.tx('readwrite').put({
|
||||
key,
|
||||
pageIndex,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
|
||||
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
|
||||
kind: pageMeta.kind || 'content',
|
||||
section: pageMeta.section || 'body',
|
||||
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
|
||||
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
|
||||
blob,
|
||||
size: blob.size,
|
||||
lastAccessed: Date.now()
|
||||
});
|
||||
request.onsuccess = () => {
|
||||
this.currentCacheSize += blob.size - Number(oldEntry?.size || 0);
|
||||
this.rememberCanvas(key, canvas);
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('WebGLPageCache: Failed to cache page canvas', { pageIndex, error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getPageCanvas(pageMeta = {}) {
|
||||
if (!this.db || this.cacheStatus !== 'ready') return null;
|
||||
const key = this.makePageKey(pageMeta);
|
||||
const cachedCanvas = this.memoryCanvasCache.get(key);
|
||||
if (cachedCanvas) {
|
||||
this.memoryCanvasCache.delete(key);
|
||||
this.memoryCanvasCache.set(key, cachedCanvas);
|
||||
return cachedCanvas;
|
||||
}
|
||||
try {
|
||||
const entry = await new Promise((resolve, reject) => {
|
||||
const store = this.tx('readwrite');
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => {
|
||||
const result = request.result || null;
|
||||
if (!result) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
result.lastAccessed = Date.now();
|
||||
store.put(result);
|
||||
resolve(result);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (!entry?.blob) return null;
|
||||
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
||||
if (canvas) canvas.__webglPageCacheMeta = {
|
||||
pageIndex: entry.pageIndex,
|
||||
kind: entry.kind || pageMeta.kind || 'content',
|
||||
section: entry.section || pageMeta.section || 'body',
|
||||
contentVersion: entry.contentVersion,
|
||||
completenessScore: entry.completenessScore,
|
||||
maxBlockId: entry.maxBlockId,
|
||||
lineCount: entry.lineCount
|
||||
};
|
||||
if (canvas) this.rememberCanvas(key, canvas);
|
||||
return canvas;
|
||||
} catch (error) {
|
||||
console.warn('WebGLPageCache: Failed to read cached page canvas', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// contentVersion is the monotonic authority: a higher version is always newer and
|
||||
// wins, even when the re-typeset page legitimately has fewer lines (lower
|
||||
// completeness). completenessScore only breaks ties when versions are equal/absent.
|
||||
isOlderPageEntry(pageMeta = {}, oldEntry = null) {
|
||||
if (!oldEntry) return false;
|
||||
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
|
||||
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
|
||||
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
|
||||
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
|
||||
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
|
||||
return incomingCompleteness < existingCompleteness;
|
||||
}
|
||||
|
||||
isOlderPageMeta(incoming = {}, existing = null) {
|
||||
if (!existing) return false;
|
||||
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
|
||||
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
|
||||
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
|
||||
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
|
||||
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
|
||||
return incomingCompleteness < existingCompleteness;
|
||||
}
|
||||
|
||||
recordProblem(detail = {}) {
|
||||
const entry = {
|
||||
...detail,
|
||||
at: performance.now()
|
||||
};
|
||||
this.problemLog.push(entry);
|
||||
if (this.problemLog.length > 80) this.problemLog.splice(0, this.problemLog.length - 80);
|
||||
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(this.problemLog);
|
||||
console.warn('WebGL page texture store problem', entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
getRuntimeState() {
|
||||
return {
|
||||
cacheStatus: this.cacheStatus,
|
||||
residentTextureCount: this.residentTextures.size,
|
||||
maxResidentTextureCount: this.maxResidentTextureCount,
|
||||
preparedTextureCount: this.preparedTextures.left.size + this.preparedTextures.right.size,
|
||||
preparedRevealPlanCount: this.preparedRevealPlans.size,
|
||||
pendingPageWriteCount: this.pendingPageWrites.size,
|
||||
problemCount: this.problemLog.length,
|
||||
hasRuntime: Boolean(this.textureRuntime?.THREE && this.textureRuntime?.renderer),
|
||||
hasBlankTexture: Boolean(this.blankTexture)
|
||||
};
|
||||
}
|
||||
|
||||
canvasToBlob(canvas) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof canvas.toBlob !== 'function') {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
canvas.toBlob(resolve, 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
async blobToCanvas(blob, width, height) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(Number(width || 1)));
|
||||
canvas.height = Math.max(1, Math.round(Number(height || 1)));
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return null;
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
bitmap.close?.();
|
||||
return canvas;
|
||||
}
|
||||
|
||||
rememberCanvas(key, canvas) {
|
||||
this.memoryCanvasCache.set(key, canvas);
|
||||
while (this.memoryCanvasCache.size > this.maxMemoryCanvasCount) {
|
||||
const oldestKey = this.memoryCanvasCache.keys().next().value;
|
||||
this.memoryCanvasCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
async manageCacheSize(sizeToAdd = 0) {
|
||||
if (!this.db || this.cacheStatus !== 'ready') return;
|
||||
if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) return;
|
||||
const entries = await new Promise((resolve, reject) => {
|
||||
const results = [];
|
||||
const request = this.tx('readonly').index('lastAccessed').openCursor();
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(results);
|
||||
return;
|
||||
}
|
||||
results.push({
|
||||
key: cursor.value.key,
|
||||
size: Number(cursor.value.size || 0)
|
||||
});
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
for (const entry of entries) {
|
||||
if (this.currentCacheSize + sizeToAdd <= this.maxCacheSizeBytes) break;
|
||||
await this.deleteEntry(entry.key);
|
||||
this.currentCacheSize = Math.max(0, this.currentCacheSize - entry.size);
|
||||
}
|
||||
}
|
||||
|
||||
async calculateTotalCacheSize() {
|
||||
if (!this.db) return 0;
|
||||
return new Promise((resolve, reject) => {
|
||||
let total = 0;
|
||||
const request = this.tx('readonly').openCursor();
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (!cursor) {
|
||||
resolve(total);
|
||||
return;
|
||||
}
|
||||
total += Number(cursor.value.size || 0);
|
||||
cursor.continue();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
deleteEntry(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = this.tx('readwrite').delete(key);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const webglPageCache = new WebGLPageCacheModule();
|
||||
|
||||
export { webglPageCache as WebGLPageCache };
|
||||
|
||||
if (window.moduleRegistry) {
|
||||
window.moduleRegistry.register(webglPageCache);
|
||||
}
|
||||
|
||||
window.WebGLPageCache = webglPageCache;
|
||||
@@ -29,6 +29,13 @@
|
||||
"options.voice": "Stimme",
|
||||
"options.speed": "Tempo",
|
||||
"options.audio": "Audio",
|
||||
"options.bookDisplay": "Buchanzeige",
|
||||
"options.displayMode": "Anzeigemodus",
|
||||
"options.displayMode3d": "3D",
|
||||
"options.displayMode2d": "2D",
|
||||
"options.bookSize": "Buchgröße",
|
||||
"options.bookProgress": "Seitenstapel",
|
||||
"options.pageReserve": "Seitenreserve",
|
||||
"options.volume": "Lautstärke",
|
||||
"options.masterVolume": "Gesamtlautstärke",
|
||||
"options.speechVolume": "Sprachlautstärke",
|
||||
@@ -53,6 +60,20 @@
|
||||
"options.apiUrl": "API-URL",
|
||||
"options.model": "Modell",
|
||||
"options.requestTimeoutMs": "Anfrage-Timeout (ms)",
|
||||
"webgl.title": "Prozedurales Buch",
|
||||
"webgl.sceneLabel": "3D-Buchszene",
|
||||
"webgl.bookControls": "Buchsteuerung",
|
||||
"webgl.status3d": "3D-Szene",
|
||||
"webgl.status2d": "2D-Szene",
|
||||
"webgl.bookSize": "Seiten",
|
||||
"webgl.pageStackProgress": "Fortschritt",
|
||||
"webgl.page": "Seite",
|
||||
"webgl.returnToBeginning": "Zum Anfang",
|
||||
"webgl.goToEnd": "Zum Ende",
|
||||
"webgl.fastBackward": "Schnell zurück",
|
||||
"webgl.backward": "Zurück",
|
||||
"webgl.forward": "Vorwärts",
|
||||
"webgl.fastForward": "Schnell vorwärts",
|
||||
"credits.button": "Credits",
|
||||
"credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen",
|
||||
"credits.title": "Mitwirkende und Lizenzen",
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
"options.voice": "Voice",
|
||||
"options.speed": "Speed",
|
||||
"options.audio": "Audio",
|
||||
"options.bookDisplay": "Book Display",
|
||||
"options.displayMode": "Display Mode",
|
||||
"options.displayMode3d": "3D",
|
||||
"options.displayMode2d": "2D",
|
||||
"options.bookSize": "Book Size",
|
||||
"options.bookProgress": "Page Stack",
|
||||
"options.pageReserve": "Page Reserve",
|
||||
"options.volume": "Volume",
|
||||
"options.masterVolume": "Master Volume",
|
||||
"options.speechVolume": "Speech Volume",
|
||||
@@ -53,6 +60,20 @@
|
||||
"options.apiUrl": "API URL",
|
||||
"options.model": "Model",
|
||||
"options.requestTimeoutMs": "Request timeout (ms)",
|
||||
"webgl.title": "Procedural Book",
|
||||
"webgl.sceneLabel": "3D book scene",
|
||||
"webgl.bookControls": "Book controls",
|
||||
"webgl.status3d": "3D scene",
|
||||
"webgl.status2d": "2D scene",
|
||||
"webgl.bookSize": "Pages",
|
||||
"webgl.pageStackProgress": "Progress",
|
||||
"webgl.page": "Page",
|
||||
"webgl.returnToBeginning": "Return to beginning",
|
||||
"webgl.goToEnd": "Go to end",
|
||||
"webgl.fastBackward": "Fast backward",
|
||||
"webgl.backward": "Backward",
|
||||
"webgl.forward": "Forward",
|
||||
"webgl.fastForward": "Fast forward",
|
||||
"credits.button": "credits",
|
||||
"credits.buttonTitle": "Show credits and third-party licenses",
|
||||
"credits.title": "Credits and Licenses",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<!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;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 14px;
|
||||
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: auto;
|
||||
}
|
||||
|
||||
#lab_title {
|
||||
font-size: 15px;
|
||||
letter-spacing: 0;
|
||||
color: #f1dec0;
|
||||
}
|
||||
|
||||
#lab_status {
|
||||
font-size: 13px;
|
||||
color: rgba(241, 222, 192, 0.72);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#book_controls {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 12px;
|
||||
color: rgba(241, 222, 192, 0.86);
|
||||
}
|
||||
|
||||
.control_group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.control_group label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#book_controls input[type="range"] {
|
||||
width: clamp(96px, 15vw, 230px);
|
||||
accent-color: #d79b36;
|
||||
}
|
||||
|
||||
#book_controls output {
|
||||
min-width: 38px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(241, 222, 192, 0.76);
|
||||
}
|
||||
|
||||
.transport_button {
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(214, 180, 125, 0.32);
|
||||
background: rgba(37, 19, 11, 0.72);
|
||||
color: #f1dec0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.transport_button:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#lab_status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#book_controls {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.control_group label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</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="book_controls" aria-label="Book controls">
|
||||
<button class="transport_button" id="fast_backward" type="button" title="Fast backward" aria-label="Fast backward">⏪</button>
|
||||
<button class="transport_button" id="flip_backward" type="button" title="Backward" aria-label="Backward">◀</button>
|
||||
<div class="control_group">
|
||||
<label for="progress_control">Progress</label>
|
||||
<input id="progress_control" type="range" min="0" max="1" step="0.001">
|
||||
<output id="progress_value" for="progress_control">0.28</output>
|
||||
</div>
|
||||
<div class="control_group">
|
||||
<label for="page_count_control">Pages</label>
|
||||
<input id="page_count_control" type="range" min="40" max="500" step="10">
|
||||
<output id="page_count_value" for="page_count_control">240</output>
|
||||
</div>
|
||||
<button class="transport_button" id="flip_forward" type="button" title="Forward" aria-label="Forward">▶</button>
|
||||
<button class="transport_button" id="fast_forward" type="button" title="Fast forward" aria-label="Fast forward">⏩</button>
|
||||
</div>
|
||||
<div id="lab_status">standalone scene</div>
|
||||
</div>
|
||||
<script type="module" src="/js/webgl-book-lab.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Book Shape Lab</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #202124;
|
||||
color: #eeeeee;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#scene {
|
||||
display: block;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#shape_panel {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
max-width: 720px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(20, 20, 20, 0.86);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#progress,
|
||||
#page_count {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 32px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
background: #2b2d30;
|
||||
color: #f0f0f0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="scene" aria-label="Procedural book shape lab"></canvas>
|
||||
<div id="shape_panel">
|
||||
<label for="progress">Reading progress</label>
|
||||
<input id="progress" type="range" min="0" max="1" step="0.001" value="0.25">
|
||||
<output id="progress_value" for="progress">0.25</output>
|
||||
<label for="page_count">Book pages</label>
|
||||
<input id="page_count" type="range" min="40" max="600" step="10" value="240">
|
||||
<output id="page_count_value" for="page_count">240</output>
|
||||
<button id="fast_backward" type="button">Fast Backward</button>
|
||||
<button id="flip_backward" type="button">Backward</button>
|
||||
<button id="flip_forward" type="button">Forward</button>
|
||||
<button id="fast_forward" type="button">Fast Forward</button>
|
||||
<output id="flip_count">0 / 10</output>
|
||||
</div>
|
||||
<script type="module" src="/js/webgl-book-shape-lab.js?v=page-ratio-cover-width-1"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,280 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const sourcePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-lab.js');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const proceduralBookPath = path.join(__dirname, '..', 'public', 'js', 'procedural-book-model.js');
|
||||
const proceduralBookSource = fs.readFileSync(proceduralBookPath, 'utf8');
|
||||
const textureRendererPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-renderer-module.js');
|
||||
const textureRendererSource = fs.readFileSync(textureRendererPath, 'utf8');
|
||||
const playbackCoordinatorPath = path.join(__dirname, '..', 'public', 'js', 'playback-coordinator-module.js');
|
||||
const playbackCoordinatorSource = fs.readFileSync(playbackCoordinatorPath, 'utf8');
|
||||
const uiDisplayHandlerPath = path.join(__dirname, '..', 'public', 'js', 'ui-display-handler-module.js');
|
||||
const uiDisplayHandlerSource = fs.readFileSync(uiDisplayHandlerPath, 'utf8');
|
||||
const bookPaginationPath = path.join(__dirname, '..', 'public', 'js', 'book-pagination-module.js');
|
||||
const bookPaginationSource = fs.readFileSync(bookPaginationPath, 'utf8');
|
||||
const sentenceQueuePath = path.join(__dirname, '..', 'public', 'js', 'sentence-queue-module.js');
|
||||
const sentenceQueueSource = fs.readFileSync(sentenceQueuePath, 'utf8');
|
||||
const storyHistoryPath = path.join(__dirname, '..', 'public', 'js', 'story-history-module.js');
|
||||
const storyHistorySource = fs.readFileSync(storyHistoryPath, 'utf8');
|
||||
const webglScenePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-scene-module.js');
|
||||
const webglSceneSource = fs.readFileSync(webglScenePath, 'utf8');
|
||||
const markupParserPath = path.join(__dirname, '..', 'public', 'js', 'markup-parser-module.js');
|
||||
const markupParserSource = fs.readFileSync(markupParserPath, 'utf8');
|
||||
const loaderPath = path.join(__dirname, '..', 'public', 'js', 'loader.js');
|
||||
const loaderSource = fs.readFileSync(loaderPath, 'utf8');
|
||||
const pageFormatPath = path.join(__dirname, '..', 'public', 'js', 'book-page-format-module.js');
|
||||
const pageFormatSource = fs.readFileSync(pageFormatPath, 'utf8');
|
||||
const stylePath = path.join(__dirname, '..', 'public', 'css', 'style.css');
|
||||
const styleSource = fs.readFileSync(stylePath, 'utf8');
|
||||
const optionsUiPath = path.join(__dirname, '..', 'public', 'js', 'options-ui-module.js');
|
||||
const optionsUiSource = fs.readFileSync(optionsUiPath, 'utf8');
|
||||
const persistencePath = path.join(__dirname, '..', 'public', 'js', 'persistence-manager-module.js');
|
||||
const persistenceSource = fs.readFileSync(persistencePath, 'utf8');
|
||||
const webglPageCachePath = path.join(__dirname, '..', 'public', 'js', 'webgl-page-cache-module.js');
|
||||
const webglPageCacheSource = fs.readFileSync(webglPageCachePath, 'utf8');
|
||||
const bookPlaybackTimelinePath = path.join(__dirname, '..', 'public', 'js', 'book-playback-timeline-module.js');
|
||||
const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8');
|
||||
const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js');
|
||||
const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8');
|
||||
const textureWorkerPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-worker.js');
|
||||
const textureWorkerSource = fs.readFileSync(textureWorkerPath, 'utf8');
|
||||
|
||||
function dependencyList(source, moduleId) {
|
||||
const classStart = source.indexOf(`super('${moduleId}'`);
|
||||
if (classStart < 0) return [];
|
||||
const dependencyMatch = source.slice(classStart).match(/this\.dependencies\s*=\s*\[([^\]]*)\]/);
|
||||
if (!dependencyMatch) return [];
|
||||
return Array.from(dependencyMatch[1].matchAll(/'([^']+)'|"([^"]+)"/g)).map(match => match[1] || match[2]);
|
||||
}
|
||||
|
||||
function directGetModules(source) {
|
||||
return Array.from(source.matchAll(/getModule\('([^']+)'\)|getModule\("([^"]+)"\)/g)).map(match => match[1] || match[2]);
|
||||
}
|
||||
|
||||
function undeclaredDirectDependencies(source, moduleId, optional = []) {
|
||||
const declared = new Set(dependencyList(source, moduleId));
|
||||
const allowed = new Set([moduleId, ...declared, ...optional]);
|
||||
return Array.from(new Set(directGetModules(source))).filter(id => !allowed.has(id));
|
||||
}
|
||||
|
||||
function cacheBuster(source) {
|
||||
return source.match(/MODULE_CACHE_BUSTER\s*=\s*'([^']+)'/)?.[1] || null;
|
||||
}
|
||||
|
||||
function methodBody(source, methodName) {
|
||||
const start = source.indexOf(`${methodName}(`);
|
||||
if (start < 0) return '';
|
||||
const braceStart = source.indexOf('{', start);
|
||||
if (braceStart < 0) return '';
|
||||
let depth = 0;
|
||||
for (let index = braceStart; index < source.length; index += 1) {
|
||||
if (source[index] === '{') depth += 1;
|
||||
if (source[index] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(braceStart + 1, index);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function sourceOrder(source, first, second) {
|
||||
const firstIndex = source.indexOf(first);
|
||||
const secondIndex = source.indexOf(second);
|
||||
return firstIndex >= 0 && secondIndex >= 0 && firstIndex < secondIndex;
|
||||
}
|
||||
|
||||
const checks = [
|
||||
['scene-level SSAO import', /SSAOPass/.test(source)],
|
||||
['postprocess anti-aliasing import', /SMAAPass/.test(source)],
|
||||
['composer uses explicit render target', /new THREE\.WebGLRenderTarget\(1, 1/.test(source) && /new EffectComposer\(renderer, sceneComposerTarget\)/.test(source)],
|
||||
['composer render path is active', /composer\.render\(\)/.test(source)],
|
||||
['static table maps are loaded from disk', /table_normal_2k\.png/.test(source) && /table_dust_4k\.png/.test(source) && /table_grease_4k\.png/.test(source)],
|
||||
['runtime table map generators removed from page', !/function createTableNormalTexture|function createTableDustTexture|function createTableGreaseTexture/.test(source)],
|
||||
['table primitive shadow receiving disabled', /tableMesh\.receiveShadow = false/.test(source)],
|
||||
['flames excluded from AO', /excludeFromAo = true/.test(source) && /aoExcludedObjects\.add\(child\)/.test(source)],
|
||||
['AO pass hides excluded objects with cleanup', /sceneAoPass\.render = \(\.\.\.args\) =>/.test(source) && /finally/.test(source)],
|
||||
['AO uses scene-scale sampling', /new SSAOPass\(scene, camera, 1, 1, 64\)/.test(source) && /sceneAoPass\.kernelRadius = 0\.48/.test(source) && /sceneAoPass\.minDistance = 0\.00025/.test(source) && /sceneAoPass\.maxDistance = 0\.065/.test(source)],
|
||||
['AO debug shows blurred occlusion map', /tableDebugName === 'ao' && SSAOPass\.OUTPUT\?\.Blur/.test(source) && /sceneAoPass\.output = SSAOPass\.OUTPUT\.Blur/.test(source)],
|
||||
['direct candle shadow lobe present', /candlePlanarShadowLobe/.test(source) && /candlePlanarShadowField/.test(source)],
|
||||
['direct candle shadow contributes to final table shader', /max\(candleProjectedShadowField\(vTableWorldPosition\), candlePlanarShadowField\(vTableWorldPosition\)\)/.test(source) && /bookMeshShadowField\(vTableWorldPosition\)/.test(source)],
|
||||
['book shadows use real light-space depth maps', /bookShadowTargets/.test(source) && /MeshDepthMaterial/.test(source) && /updateBookShadowMaps/.test(source) && /bookMeshShadowField/.test(source) && /bookShadowMaps\[0\]/.test(source)],
|
||||
['book materials receive real shadow maps', /configureBookShadowReceiver\(materials\.leftPage/.test(source) && /bookReceiverShadowField/.test(source) && /bookShadowReceiverStrength/.test(source) && /configureMaterial\(material, part\)/.test(source)],
|
||||
['book uses modular solved procedural body geometry', /createProceduralBookModel/.test(source) && /currentProceduralBookModel/.test(source) && /simulatePageLines/.test(proceduralBookSource) && /createLoftedLineBody/.test(proceduralBookSource) && /buildSupportSolvedLine/.test(proceduralBookSource)],
|
||||
['proxy book shadow shortcuts are forbidden', !/bookPlanarShadowLobe|bookProjectedShadowField|bookBoxShadow|segmentBoxHit/.test(source)],
|
||||
['final candle shadow is visible in composite', /candleOcclusion = clamp\(candleProjectedShadow \* 1\.46, 0\.0, 0\.82\)/.test(source) && /vec3\(0\.19, 0\.15, 0\.115\), candleOcclusion/.test(source)],
|
||||
['primitive candle shadow shortcuts stay disabled', /wax\.castShadow = false/.test(source) && /wick\.castShadow = false/.test(source) && !/bookPlanarShadowLobe|bookProjectedShadowField|bookBoxShadow|segmentBoxHit/.test(source)],
|
||||
['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)],
|
||||
['debug AO remains scene-level', /scene debug: SSAO/.test(source)],
|
||||
['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)],
|
||||
['render readiness flag and cache key are exposed', /BookLabDebug\.ready/.test(source) && /BookLabDebug\.renderedFrames/.test(source) && /cacheKey: window\.MODULE_CACHE_BUSTER/.test(source)],
|
||||
['3D playback bypasses DOM word animation scheduling', /isWebGLPlaybackMode/.test(playbackCoordinatorSource) && /if \(this\.isWebGLPlaybackMode\(\)\)/.test(playbackCoordinatorSource) && /scheduleWebGLReveal/.test(playbackCoordinatorSource)],
|
||||
['3D UI defers rendered history mark until playback completes', /deferRenderedMark/.test(uiDisplayHandlerSource) && /prepareWebGLBookReveal/.test(uiDisplayHandlerSource) && /markBlockRendered\(sentence\.blockId/.test(uiDisplayHandlerSource)],
|
||||
['pagination can build a pending unrendered 3D block', /preparePendingBlock/.test(bookPaginationSource) && /book-pagination:prepare-block/.test(bookPaginationSource)],
|
||||
['texture renderer has separate prepare and start reveal phases', /prepareRevealBlock/.test(textureRendererSource) && /startPreparedRevealAnimation/.test(textureRendererSource) && /webgl-book:page-reveal-start/.test(textureRendererSource)],
|
||||
['texture renderer publishes line reveal coordinates from final page layout', /buildRevealRegions/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /getLineInkRect/.test(textureRendererSource) && /fixedDurationMs/.test(textureRendererSource)],
|
||||
['texture renderer carries page side through reveal region normalization', /normalizeRevealRegion\(side, blockId, lineRecord/.test(textureRendererSource) && /normalizeRevealRegion\(side, blockId, lineRecord, x, y, width, height/.test(textureRendererSource) && /normalizeRevealRegion\(side, blockId, lineRecord, rect\.x, rect\.y, rect\.width, rect\.height/.test(textureRendererSource)],
|
||||
['texture renderer does not call removed word reveal recorder', !/recordRevealRect/.test(textureRendererSource)],
|
||||
['page reveal shader uses line coordinate mask instead of comparing page textures', /bookRevealRegionRects/.test(source) && /bookRevealRegionTimings/.test(source) && /bookRevealElapsedMs/.test(source) && !/texture2D\(bookRevealMap/.test(source)],
|
||||
['page reveal shader keeps a fixed loop without dynamic break', /float enabled = step\(float\(i\) \+ 0\.5, float\(bookRevealRegionCount\)\)/.test(source) && !/if \(i >= bookRevealRegionCount\) break/.test(source)],
|
||||
['texture renderer explicitly gates initial font before painting', /waitForTextureFonts/.test(textureRendererSource) && /ensureTextureFontFace/.test(textureRendererSource) && /FontFace\(family/.test(textureRendererSource) && /document\.fonts\.load\('72px "EB Garamond Initials"'\)/.test(textureRendererSource)],
|
||||
['texture renderer no longer republishes stale scene-ready textures', !/addEventListener\(document, 'webgl-book:scene-ready'/.test(textureRendererSource) && !/handleSceneReady\(\)\s*{\s*this\.publishSpread\(\)/.test(textureRendererSource) && !/drawEmptySpread/.test(textureRendererSource)],
|
||||
['prepared reveal never falls back to unmasked direct upload before shader compile', /pendingPageReveal/.test(source) && /applyPendingPageReveal/.test(source) && !/if \(!shader\?\.uniforms\) {\s*uploadPageTextureDirect\(side, sourceCanvas\)/.test(source)],
|
||||
['ui display handler declares every direct module lookup', undeclaredDirectDependencies(uiDisplayHandlerSource, 'ui-display-handler').length === 0],
|
||||
['webgl scene declares every direct module lookup', undeclaredDirectDependencies(webglSceneSource, 'webgl-book-scene').length === 0],
|
||||
['loader cache key matches webgl procedural imports', cacheBuster(loaderSource) && source.includes(`procedural-book-model.js?v=${cacheBuster(loaderSource)}`) && proceduralBookSource.length > 0],
|
||||
['webgl lab exposes loader timing diagnostics', /loaderTimings/.test(source) && /markLoaderTiming/.test(source) && /primeSceneForLoader/.test(source)],
|
||||
['webgl lab records shader compile timing during loader prime', /markLoaderTiming\('shaderCompile:start'\)/.test(source) && /renderer\.compile\(scene, camera\)/.test(source) && /markLoaderTiming\('shaderCompile:end'\)/.test(source)],
|
||||
['webgl lab sizes render targets before static loader prime', /await reportLabStep\(86, 'Preparing static shadow and mirror maps'\);\s*resize\(\);\s*primeSceneForLoader\(\);/.test(source) && /lastResizeWidth/.test(source) && /lastResizeHeight/.test(source)],
|
||||
['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealRegionCount/.test(source)],
|
||||
['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)],
|
||||
['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)],
|
||||
['webgl reveal start survives event-before-state ordering', /function getRevealStartTimeForBlockIds/.test(source) && /activeRevealBlockStarts\.set\(pendingBlockId, now\)/.test(source) && /pendingRevealStartBlockIds\.delete\(pendingBlockId\)/.test(source)],
|
||||
['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)],
|
||||
['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)],
|
||||
['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)],
|
||||
['webgl lab binds visible page texture sources through the single texture store', /bindPageTextureSource/.test(source) && /bindVisibleTextureSource/.test(source) && /registerVisibleTexture/.test(webglPageCacheSource) && !/drawCanvasPageTexture/.test(methodBody(source, 'uploadPageTextureDirect')) && !/drawCanvasPageTexture/.test(methodBody(source, 'beginPageReveal'))],
|
||||
['page texture dark-pixel sampling only runs in table debug mode', /function shouldSamplePageTextureDebug\(\)/.test(source) && /tableDebugMode !== tableDebugModes\.none/.test(source) && /shouldSamplePageTextureDebug\(\) \? countPageTextureDarkPixels\(canvas\) : null/.test(source)],
|
||||
['texture renderer exposes reveal pipeline diagnostics', /pipelineTimings/.test(textureRendererSource) && /markPipelineTiming/.test(textureRendererSource) && /webglTexturePipeline/.test(textureRendererSource)],
|
||||
['texture renderer records prepare draw publish and start reveal timing', /markPipelineTiming\('prepareRevealBlock:start'/.test(textureRendererSource) && /markPipelineTiming\('drawSpread:start'/.test(textureRendererSource) && /markPipelineTiming\('publishSpread'/.test(textureRendererSource) && /markPipelineTiming\('startPreparedRevealAnimation'/.test(textureRendererSource)],
|
||||
['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.test(textureRendererSource)],
|
||||
['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)],
|
||||
['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)],
|
||||
['sentence queue starts future lookahead only after current display playback is entered and idle', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*const playbackFinished = new Promise/.test(sentenceQueueSource) && /this\.onSentenceReadyCallback\(sentence, resolve\);[\s\S]*this\.scheduleLookaheadAfterDisplay\(item, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource) && /scheduleLookaheadAfterDisplay\(item, queueGeneration = this\.queueGeneration\) \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\)[\s\S]*requestAnimationFrame[\s\S]*requestIdleCallback/.test(sentenceQueueSource)],
|
||||
['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)],
|
||||
['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)],
|
||||
['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)],
|
||||
['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)],
|
||||
['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)],
|
||||
['texture worker sends the static paper base bitmap once per side and the renderer reuses it', /sentBaseKeys/.test(textureWorkerSource) && /const baseKey = `\$\{side\}:\$\{width\}x\$\{height\}`/.test(textureWorkerSource) && /this\.cachedBaseCanvas\[side\] = this\.canvasFromBitmap/.test(textureRendererSource) && /this\.revealBaseCanvases\[side\] = this\.cachedBaseCanvas\?\.\[side\]/.test(textureRendererSource)],
|
||||
['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource)],
|
||||
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
|
||||
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
|
||||
['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && !/hasPreparedRevealBlock/.test(textureRendererSource)],
|
||||
['webgl page cache is loaded through module infrastructure', /webgl-page-cache-module\.js/.test(loaderSource) && /super\('webgl-page-cache'/.test(webglPageCacheSource) && /reportProgress\(20, 'Opening WebGL page texture cache'\)/.test(webglPageCacheSource)],
|
||||
['webgl page cache uses an isolated browser database without upgrading tts history state', /this\.dbName = 'webglPageTextureCacheDB'/.test(webglPageCacheSource) && /this\.dbVersion = 1/.test(webglPageCacheSource) && /this\.dbVersion = 3/.test(ttsFactorySource) && /this\.dbVersion = 3/.test(storyHistorySource) && !/webglPageTextureStore/.test(ttsFactorySource) && !/webglPageTextureStore/.test(storyHistorySource)],
|
||||
['texture renderer hands completed page canvases to the single texture store without owning write queues', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /storePageCanvas\(pageMeta, canvas, \{ persist: true, resident: true \}\)/.test(textureRendererSource) && !/schedulePageCacheWrite/.test(textureRendererSource) && !/pendingPageCacheWrites/.test(textureRendererSource)],
|
||||
['webgl texture store is non-optional with db memory cache prepared textures and vram cache', /maxCacheSizeBytes = 5 \* 1024 \* 1024 \* 1024/.test(webglPageCacheSource) && /maxMemoryCanvasCount = 256/.test(webglPageCacheSource) && /residentTextures = new Map/.test(webglPageCacheSource) && /preparedTextures = \{/.test(webglPageCacheSource) && /persistent page caching is in a problem state/.test(webglPageCacheSource) && !/if \(this\.memoryCanvasCache\.has\(key\)\) return true/.test(webglPageCacheSource)],
|
||||
['webgl lab prewarms navigation texture window through single store before flips', /const maxResidentPageTextures = 192/.test(source) && /configureTextureRuntime/.test(source) && /prewarmNavigationTextureWindow/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /resolveFlipBackTexture\(targetBackPageMeta, prewarmedBackTexture\)/.test(source) && !/const residentPageTextures = new Map/.test(source)],
|
||||
['webgl texture store records cache misses as problem states', /problemLog/.test(webglPageCacheSource) && /recordProblem/.test(webglPageCacheSource) && /db-cache-miss/.test(webglPageCacheSource) && /webglPageCacheProblems/.test(webglPageCacheSource)],
|
||||
['webgl lab makes preload-only page canvases resident by explicit page metadata through store', /pageTextureStore\?\.preparePageTexture/.test(source) && /attachRevealPageMeta/.test(source) && source.includes('pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.left, texture, detail.left, true)') && source.includes('pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.right, texture, detail.right, true)')],
|
||||
['webgl texture store keeps current visible page textures resident without disposing shared maps', /rememberResidentTexture\(pageMeta = \{\}, texture = null, sourceCanvas = null, ownsTexture = true\)/.test(webglPageCacheSource) && /ownsTexture/.test(webglPageCacheSource) && /if \(oldest\?\.ownsTexture\) oldest\.texture\?\.dispose\?\.\(\)/.test(webglPageCacheSource)],
|
||||
['webgl lab reuses current-enough resident cached page textures via single store for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && source.includes('pageTextureStore?.getResidentTextureForMeta?.(pageMeta)') && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, effectivePageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, effectivePageMeta\.right\)/.test(source) && !/function getResidentPageTextureForMeta/.test(source)],
|
||||
['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.test(webglPageCacheSource)],
|
||||
['webgl page cache rejects older page versions for the same page key', /isOlderPageEntry/.test(webglPageCacheSource) && /contentVersion/.test(webglPageCacheSource) && /completenessScore/.test(webglPageCacheSource) && /if \(this\.isOlderPageEntry\(pageMeta, oldEntry\)\) return true/.test(webglPageCacheSource)],
|
||||
['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)],
|
||||
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
|
||||
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
|
||||
['texture renderer front-loads worker fonts before the first draw so a cold render is not cut short by the timeout', /fonts-ready/.test(textureWorkerSource) && /this\.resolveFontsReady/.test(textureRendererSource) && /await this\.waitForWorkerFonts\(\)/.test(textureRendererSource) && /await this\.drawSpread\(this\.currentSpread\)/.test(textureRendererSource)],
|
||||
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
|
||||
['3D overflow reveal commits the spread then starts a prepared timeline flip before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /sceneControl\.prewarmPageFlip/.test(bookPlaybackTimelineSource) && /sceneControl\.startPreparedPageFlip/.test(bookPlaybackTimelineSource) && !/dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /prewarmPageFlip: \(direction = 1, options = \{\}\)/.test(source) && /startPreparedPageFlip: \(direction = 1, options = \{\}\)/.test(source)],
|
||||
['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)],
|
||||
['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)],
|
||||
['texture renderer recovers from worker error/timeout so a draw promise never hangs the chain', /this\.rasterWorker\.onerror/.test(textureRendererSource) && /texture-worker-timeout/.test(textureRendererSource) && /settleRasterization/.test(textureRendererSource) && /clearTimeout\(pending\.timer\)/.test(textureRendererSource)],
|
||||
['flip prewarm awaits the async worker draw before the resident-texture lookup', /await prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /await window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\]/.test(source)],
|
||||
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
||||
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
||||
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
||||
['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)],
|
||||
['webgl reveal line timings use global area-weighted timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)],
|
||||
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
|
||||
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
|
||||
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
|
||||
['3D live text bypasses #page_right DOM rendering and uses the timeline-owned book reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource) && !/if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
|
||||
['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")],
|
||||
['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)],
|
||||
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
|
||||
['webgl scene avoids duplicate initial texture publish', !/this\.triggerTextureRefresh\(\)/.test(methodBody(webglSceneSource, 'initializeScene'))],
|
||||
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
|
||||
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
|
||||
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
|
||||
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.pages = this\.buildPages\(\[\]\);/.test(bookPaginationSource) && /this\.currentSpreadIndex = 0;[\s\S]*this\.publish\(\{ reason: 'initial-title-spread', visibility: 'future-ready' \}\);/.test(bookPaginationSource)],
|
||||
['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)],
|
||||
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
|
||||
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
||||
['texture worker draws title page and page numbers; renderer marshals title data and versioned page metadata', /drawTitlePage/.test(textureWorkerSource) && /drawPageNumber/.test(textureWorkerSource) && /game_title/.test(textureRendererSource) && /buildTitleData/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
||||
['texture worker uses plural page margin metrics for page numbers', /metrics\.margins\.bottom/.test(textureWorkerSource) && !/metrics\.margin\.bottom/.test(textureWorkerSource)],
|
||||
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
||||
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
||||
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = await this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
||||
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
||||
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)],
|
||||
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
|
||||
['webgl flip page material variants are compiled during loader, not at first texture swap', /flipPageSurface: new THREE\.MeshStandardMaterial\(\{[\s\S]*map: getBlankPageTexture\(\),[\s\S]*normalMap: paperTextures\.normal,[\s\S]*roughnessMap: paperTextures\.roughness/.test(source) && !/materials\.flipPageSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip')) && !/materials\.flipPageBackSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
|
||||
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
|
||||
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
|
||||
['webgl flip geometry hinges the flip sheet at the spine using the raw page line', !/normalizeFlipLineToVisiblePage/.test(source) && /const sourceLine = topVisibleLine\(sourceSide\)/.test(source) && /const destinationLine = topVisibleLine\(-sourceSide\)/.test(source) && /lerp\(sourceLine\.anchor\.x, destinationLine\.anchor\.x, t\)/.test(source)],
|
||||
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
|
||||
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
||||
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
|
||||
['webgl reveal playback throttles dynamic buffers without freezing mirror permanently', /const revealGeometryBufferRefreshIntervalMs = 1000 \/ 4/.test(source) && /const revealAnimating = hasActivePageReveal\(\)/.test(source) && /revealAnimating[\s\S]*revealGeometryBufferRefreshIntervalMs/.test(source)],
|
||||
['webgl navigation texture prewarm yields until reveal and flip critical frames are clear', /function scheduleNavigationTextureWindowPrewarm/.test(source) && /requestIdleCallback/.test(source) && /activeFlips\.length > 0 \|\| hasActivePageReveal\(\)/.test(source) && /scheduleNavigationTextureWindowPrewarm\('page-texture-records'/.test(source)],
|
||||
['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)],
|
||||
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
||||
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
|
||||
['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)],
|
||||
['webgl page navigation is page-count based with explicit spread mapping', /function pageToSpreadIndex/.test(source) && /Math\.floor\(page \/ 2\) \+ 1/.test(source) && /function spreadIndexToPagePosition/.test(source) && /\(spread - 1\) \* 2/.test(source)],
|
||||
['webgl reading progress sync does not rebuild pagination as a page-count change', /function syncReadingProgressToCurrentPage/.test(source) && !/notifyBookPageCountChanged/.test(methodBody(source, 'syncReadingProgressToCurrentPage'))],
|
||||
['webgl page reserve grows book size without shrinking', /function growBookIfWritableLimitReached/.test(source) && /bookPageCount < PROCEDURAL_BOOK\.PAGE_COUNT_MAX/.test(source) && /snapProceduralPageCount\(bookPageCount \+ PROCEDURAL_BOOK\.PAGE_COUNT_STEP\)/.test(source) && /bookPageCount = Math\.max\(nextPageCount, bookPageCount\)/.test(source)],
|
||||
['webgl bottom navigation shows media buttons and endpoint labels', /webgl_book_navigation/.test(source) && /webgl_book_nav_min_label/.test(source) && /webgl_book_nav_max_label/.test(source) && /webgl-book-nav-slider-track/.test(styleSource)],
|
||||
['webgl page reserve options replace old progress slider and hide fixed metadata values', /data-pref-bind': 'webgl\.pageReserve'/.test(optionsUiSource) && /hasFixedBookPageCount/.test(optionsUiSource) && /hasFixedPageReserve/.test(optionsUiSource) && !/data-pref-bind': 'webgl\.bookProgress'/.test(optionsUiSource)],
|
||||
['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)],
|
||||
['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)],
|
||||
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
|
||||
['webgl right-page reveal flips are owned by the timeline, not the scene', !/pendingRightPageFlip/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && /waitForVisualCompletion/.test(bookPlaybackTimelineSource) && /reason: 'timeline-right-page-filled'/.test(bookPlaybackTimelineSource) && /requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /isChoiceAwaitingPlayer/.test(bookPlaybackTimelineSource)],
|
||||
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
|
||||
['webgl line reveal timing scales total by word-share for partial blocks and splits per-line by area', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /const useWordShare = totalBlockWords > 0 && collectedWords > 0 && collectedWords < totalBlockWords/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)],
|
||||
['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)],
|
||||
['webgl ordinary flip near-end uses resident target textures and defers revealing sides', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end', \{ skipSides: flip\.deferRevealSides \}\)/.test(source) && /function applyResidentSpreadTextures\(spreadIndex, reason = 'resident-spread', options = \{\}\)/.test(source) && /const skipSides = Array\.isArray\(options\.skipSides\)/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:state-only/.test(source)],
|
||||
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
|
||||
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
|
||||
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
|
||||
['webgl scene awaits current pagination spread redraw during loader initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /await window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && !/Date\.now\(\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
|
||||
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)],
|
||||
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
|
||||
['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)],
|
||||
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
|
||||
['texture renderer prepares a spanning block continuation spread in the background and reuses it (no synchronous redraw on the critical path)', /revealSpreadSourceOverride/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(textureRendererSource) && /takeContinuationRevealPlan/.test(textureRendererSource) && /`\$\{id\}:cont`/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /takeContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /previewSpreads/.test(bookPaginationSource)],
|
||||
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
|
||||
['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline reuses prepared activation texture plan on the critical path', /let texturePlan = segment\.preparedTexturePlan/.test(bookPlaybackTimelineSource) && /\{ \.\.\.segment\.preparedTexturePlan, phase: 'activate' \}/.test(bookPlaybackTimelineSource) && /takePreparedRevealPlan\(segment\.blockId\)/.test(bookPlaybackTimelineSource) && /if \(!texturePlan\) \{[\s\S]*prepareRevealBlock/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline compares preplay flip against source spread captured before commit', /segment\.sourceSpreadIndex = this\.getVisibleSpreadIndex\(\)/.test(bookPlaybackTimelineSource) && /segment\.sourceSpreadIndex = Number\.isFinite/.test(bookPlaybackTimelineSource) && /const sourceSpread = Number\.isFinite/.test(bookPlaybackTimelineSource) && /targetSpreadIndex \|\| 0\)\) > sourceSpread/.test(bookPlaybackTimelineSource)],
|
||||
['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)],
|
||||
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(\s*[\s\S]*revealDetail[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)],
|
||||
['3D display playback is owned by book playback timeline', /book-playback-timeline/.test(uiDisplayHandlerSource) && /playWebGLBookSentence/.test(uiDisplayHandlerSource) && /timeline\.playSentence\(sentence\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
|
||||
['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)],
|
||||
['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline enforces resident page textures before prepared playback', /assertSegmentReady/.test(bookPlaybackTimelineSource) && /collectRequiredPageMetas/.test(bookPlaybackTimelineSource) && /collectTexturePlanPageMetas/.test(bookPlaybackTimelineSource) && /this\.pageCache\.ensurePageTexture\(meta/.test(bookPlaybackTimelineSource) && /timeline-cache-readiness-failed/.test(bookPlaybackTimelineSource) && !/spreads\.add\(currentSpread \+ 1\)/.test(bookPlaybackTimelineSource)],
|
||||
['3D reveal start is owned by the timeline and dispatched to the single scene clock', /sentence\.webglRevealController = \(\) => this\.startRevealForSegment\(segment\)/.test(bookPlaybackTimelineSource) && /startPreparedRevealAnimation\?\.\(segment\.blockId, \{[\s\S]*publishEvent: true/.test(bookPlaybackTimelineSource) && /PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller/.test(playbackCoordinatorSource) && !/document\.dispatchEvent\(new CustomEvent\('book-texture:reveal-block'/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal'))],
|
||||
['webgl scene reports reveal commits but does not own flips and no ownership flag survives', /dispatchEvent\(new CustomEvent\('webgl-book:reveal-committed'/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && !/ownsPageFlipCommit/.test(source) && !/ownsPageFlipCommit/.test(textureRendererSource) && !/ownsPageFlipCommit/.test(bookPlaybackTimelineSource)],
|
||||
['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)],
|
||||
['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
|
||||
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
||||
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
||||
['webgl navigation is spread-based and caps at the written-content spread (title-only before content)', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, contentSpread, spreadCount - 1\)/.test(source) && /writtenPageLimit >= 3 \? pageToSpreadIndex\(writtenPageLimit\) : 0/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
|
||||
['webgl spread label reads 0 at the title and the right page number elsewhere', /function spreadPageLabel\(spreadIndex\)/.test(source) && /if \(spread <= 0\) return '0'/.test(source) && /spreadPageIndices\(spread\)\.right/.test(source) && /rightPageIndex - 2/.test(source)],
|
||||
['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)],
|
||||
['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)],
|
||||
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
||||
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
||||
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
||||
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
|
||||
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
|
||||
];
|
||||
|
||||
const failures = checks.filter(([, passed]) => !passed).map(([name]) => name);
|
||||
|
||||
if (failures.length) {
|
||||
console.error('WebGL book lab regression checks failed:');
|
||||
failures.forEach((name) => console.error(`- ${name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`WebGL book lab regression checks passed (${checks.length}).`);
|
||||
@@ -0,0 +1,282 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const targetUrl = process.env.WEBGL_RUNTIME_URL || 'http://localhost:3001/';
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
const errors = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') errors.push(message.text());
|
||||
});
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.removeItem('ai-interactive-fiction-preferences');
|
||||
});
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForFunction(() => window.BookTextureRenderer && window.BookLabDebug, null, { timeout: 180000 });
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
window.BookTextureRenderer.publishSpread();
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
|
||||
const nav = document.getElementById('webgl_book_navigation');
|
||||
const slider = document.getElementById('webgl_book_nav_position');
|
||||
const minLabel = document.getElementById('webgl_book_nav_min_label');
|
||||
const maxLabel = document.getElementById('webgl_book_nav_max_label');
|
||||
const textureInfo = window.BookLabDebug.getTextureInfo();
|
||||
const runtimeInvariants = window.BookLabDebug.getRuntimeInvariants?.() || {};
|
||||
const initialBookState = window.BookLabDebug.getBookState();
|
||||
const initialSliderMax = slider?.max || null;
|
||||
const initialMinLabel = minLabel?.textContent || '';
|
||||
const initialMaxLabel = maxLabel?.textContent || '';
|
||||
const pageSpreadMap = [0, 1, 2, 3, 4, 5].map(page => [page, window.BookLabDebug.mapPageToSpread(page)]);
|
||||
const spreadPageMap = [0, 1, 2, 3].map(spread => [spread, window.BookLabDebug.mapSpreadToPage(spread)]);
|
||||
const pageCache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache');
|
||||
const cacheProbeCanvas = document.createElement('canvas');
|
||||
cacheProbeCanvas.width = 8;
|
||||
cacheProbeCanvas.height = 8;
|
||||
const cacheProbeContext = cacheProbeCanvas.getContext('2d');
|
||||
cacheProbeContext.fillStyle = '#000';
|
||||
cacheProbeContext.fillRect(0, 0, 8, 8);
|
||||
const cacheProbeMeta = { pageIndex: 9999, width: 8, height: 8, cacheKey: 'runtime-probe' };
|
||||
const cacheStoreResult = await pageCache?.cachePageCanvas?.(cacheProbeMeta, cacheProbeCanvas);
|
||||
const cacheProbeResult = await pageCache?.getPageCanvas?.(cacheProbeMeta);
|
||||
|
||||
window.BookLabDebug.setPaginationStateForTest({
|
||||
spreadIndex: 0,
|
||||
spreadCount: 126,
|
||||
writtenPageLimit: 250
|
||||
});
|
||||
const grownBookState = window.BookLabDebug.getBookState();
|
||||
|
||||
window.BookLabDebug.setPaginationStateForTest({
|
||||
spreadIndex: 0,
|
||||
spreadCount: 8,
|
||||
writtenPageLimit: 10
|
||||
});
|
||||
const initialNavigationDisabled = {
|
||||
topBackward: Boolean(document.getElementById('flip_backward')?.disabled),
|
||||
topFastBackward: Boolean(document.getElementById('fast_flip_backward')?.disabled),
|
||||
bottomStart: Boolean(document.getElementById('webgl_book_nav_start')?.disabled),
|
||||
bottomBack: Boolean(document.getElementById('webgl_book_nav_back')?.disabled)
|
||||
};
|
||||
slider.value = '100';
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await new Promise(resolve => {
|
||||
const startedAt = Date.now();
|
||||
const check = () => {
|
||||
if ((window.BookLabDebug?.activeFlips || 0) === 0 || Date.now() - startedAt > 2200) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
requestAnimationFrame(check);
|
||||
});
|
||||
const clampedSliderValue = slider.value;
|
||||
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reserve-directive', {
|
||||
detail: {
|
||||
value: 20,
|
||||
unit: 'percent'
|
||||
}
|
||||
}));
|
||||
const percentReserveState = window.BookLabDebug.getBookState();
|
||||
|
||||
document.body.classList.add('webgl-mode');
|
||||
if (!document.getElementById('page_left')) {
|
||||
window.moduleRegistry?.getModule?.('ui-display-handler')?.initializeContainers?.();
|
||||
}
|
||||
window.moduleRegistry?.getModule?.('webgl-book-scene')?.moveBookToControlOverlay?.();
|
||||
const pageLeft = document.getElementById('page_left');
|
||||
let choicesPanel = document.getElementById('choices');
|
||||
if (!choicesPanel && pageLeft) {
|
||||
choicesPanel = document.createElement('div');
|
||||
choicesPanel.id = 'choices';
|
||||
choicesPanel.className = 'container';
|
||||
pageLeft.appendChild(choicesPanel);
|
||||
}
|
||||
const choicesGroup = document.createElement('div');
|
||||
choicesGroup.className = 'choices-group';
|
||||
const choiceButton = document.createElement('button');
|
||||
choiceButton.className = 'choice-button';
|
||||
choiceButton.textContent = 'A deliberately long choice label that must stay inside the WebGL overlay without creating horizontal scrolling';
|
||||
choicesGroup.appendChild(choiceButton);
|
||||
choicesPanel?.appendChild(choicesGroup);
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
const gameTitle = document.getElementById('game_title');
|
||||
const startPrompt = document.getElementById('start_prompt');
|
||||
const titleDisplay = gameTitle ? window.getComputedStyle(gameTitle).display : 'absent';
|
||||
const startPromptDisplay = startPrompt ? window.getComputedStyle(startPrompt).display : 'absent';
|
||||
const pageLeftStyle = pageLeft ? window.getComputedStyle(pageLeft) : null;
|
||||
const choicesStyle = choicesPanel ? window.getComputedStyle(choicesPanel) : null;
|
||||
const buttonStyle = window.getComputedStyle(choiceButton);
|
||||
const overlayLayout = {
|
||||
pageLeftExists: Boolean(pageLeft),
|
||||
choicesPanelExists: Boolean(choicesPanel),
|
||||
pageLeftNoHorizontalScrollbar: pageLeft ? pageLeft.scrollWidth <= pageLeft.clientWidth + 1 : false,
|
||||
choicesNoHorizontalScrollbar: choicesPanel ? choicesPanel.scrollWidth <= choicesPanel.clientWidth + 1 : false,
|
||||
pageLeftOverflowX: pageLeftStyle?.overflowX || null,
|
||||
choicesOverflowX: choicesStyle?.overflowX || null,
|
||||
titleDisplay,
|
||||
startPromptDisplay,
|
||||
buttonColor: buttonStyle.color,
|
||||
buttonBackground: buttonStyle.backgroundColor
|
||||
};
|
||||
|
||||
window.BookLabDebug.setPaginationStateForTest({
|
||||
spreadIndex: 1,
|
||||
spreadCount: 8,
|
||||
writtenPageLimit: 10
|
||||
});
|
||||
if (window.BookPagination) {
|
||||
window.BookPagination.spreads = Array.from({ length: 8 }, (_, index) => ({
|
||||
index,
|
||||
left: [],
|
||||
right: [],
|
||||
pageMeta: {}
|
||||
}));
|
||||
window.BookPagination.currentSpreadIndex = 1;
|
||||
}
|
||||
let targetFlipEventDetail = null;
|
||||
const flipFinished = new Promise(resolve => {
|
||||
document.addEventListener('webgl-book:page-flip-finished', (event) => {
|
||||
targetFlipEventDetail = event.detail || null;
|
||||
resolve(true);
|
||||
}, { once: true });
|
||||
});
|
||||
const requestedFlip = await window.BookLabDebug.startPageFlipForTest(1, {
|
||||
force: true,
|
||||
targetSpread: 2
|
||||
});
|
||||
const activeFlipsAfterRequest = window.BookLabDebug.activeFlips;
|
||||
let postAdvanceState = null;
|
||||
if (requestedFlip && window.BookLabDebug.activeFlips > 0) {
|
||||
postAdvanceState = window.BookLabDebug.advancePageFlipForTest();
|
||||
}
|
||||
const activeFlipsAfterAdvance = window.BookLabDebug.activeFlips;
|
||||
const targetFlipFinished = targetFlipEventDetail
|
||||
? true
|
||||
: await Promise.race([
|
||||
flipFinished,
|
||||
new Promise(resolve => window.setTimeout(() => resolve(false), 5000))
|
||||
]);
|
||||
const postTargetFlipState = window.BookLabDebug.getBookState();
|
||||
window.BookLabDebug.setPaginationStateForTest({
|
||||
spreadIndex: 5,
|
||||
spreadCount: 8,
|
||||
writtenPageLimit: 10
|
||||
});
|
||||
const endNavigationDisabled = {
|
||||
topForward: Boolean(document.getElementById('flip_forward')?.disabled),
|
||||
topFastForward: Boolean(document.getElementById('fast_flip_forward')?.disabled),
|
||||
bottomForward: Boolean(document.getElementById('webgl_book_nav_forward')?.disabled),
|
||||
bottomEnd: Boolean(document.getElementById('webgl_book_nav_end')?.disabled)
|
||||
};
|
||||
|
||||
return {
|
||||
navExists: Boolean(nav),
|
||||
runtimeInvariants,
|
||||
initialSliderMax,
|
||||
initialMinLabel,
|
||||
initialMaxLabel,
|
||||
finalSliderMax: slider?.max || null,
|
||||
finalMaxLabel: maxLabel?.textContent || '',
|
||||
initialBookState,
|
||||
pageSpreadMap,
|
||||
spreadPageMap,
|
||||
pageCacheReady: pageCache?.cacheStatus === 'ready',
|
||||
pageCacheProbe: {
|
||||
stored: cacheStoreResult === true,
|
||||
width: cacheProbeResult?.width || 0,
|
||||
height: cacheProbeResult?.height || 0
|
||||
},
|
||||
grownBookState,
|
||||
initialNavigationDisabled,
|
||||
clampedSliderValue,
|
||||
percentReserveState,
|
||||
overlayLayout,
|
||||
requestedFlip,
|
||||
activeFlipsAfterRequest,
|
||||
activeFlipsAfterAdvance,
|
||||
postAdvanceState,
|
||||
targetFlipFinished,
|
||||
targetFlipEventDetail,
|
||||
postTargetFlipState,
|
||||
endNavigationDisabled,
|
||||
textureInfo
|
||||
};
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
|
||||
const failures = [];
|
||||
const relevantErrors = errors.filter((error) => !/^Failed to load resource: the server responded with a status of 400/.test(error));
|
||||
if (relevantErrors.length) failures.push(`browser errors: ${relevantErrors.join(' | ')}`);
|
||||
if (!result.navExists) failures.push('bottom navigation missing');
|
||||
if (result.initialSliderMax !== '300') failures.push(`expected initial slider max 300, got ${result.initialSliderMax}`);
|
||||
if (result.initialMinLabel !== '0') failures.push(`expected min label 0, got ${result.initialMinLabel}`);
|
||||
if (result.initialMaxLabel !== '300') failures.push(`expected initial max label 300, got ${result.initialMaxLabel}`);
|
||||
if (result.initialBookState?.pageCount !== 300) failures.push(`expected initial pageCount 300, got ${result.initialBookState?.pageCount}`);
|
||||
if (result.initialBookState?.pageReserve !== 50) failures.push(`expected initial pageReserve 50, got ${result.initialBookState?.pageReserve}`);
|
||||
if (result.initialBookState?.progress !== 0) failures.push(`expected initial progress 0, got ${result.initialBookState?.progress}`);
|
||||
if (Math.abs(Number(result.runtimeInvariants?.targetFrameDurationMs || 0) - (1000 / 60)) > 0.001) {
|
||||
failures.push(`expected 60fps target frame duration, got ${result.runtimeInvariants?.targetFrameDurationMs}`);
|
||||
}
|
||||
if (result.runtimeInvariants?.flipFrontBackShareMaterial) failures.push('flip front/back materials are shared instead of independently switchable');
|
||||
if (!result.runtimeInvariants?.mirrorRefreshesEveryFrame) failures.push('mirror reflection is not marked for per-frame refresh');
|
||||
if (JSON.stringify(result.pageSpreadMap) !== JSON.stringify([[0, 0], [1, 1], [2, 2], [3, 2], [4, 3], [5, 3]])) {
|
||||
failures.push(`unexpected page-to-spread map ${JSON.stringify(result.pageSpreadMap)}`);
|
||||
}
|
||||
if (JSON.stringify(result.spreadPageMap) !== JSON.stringify([[0, 0], [1, 1], [2, 2], [3, 4]])) {
|
||||
failures.push(`unexpected spread-to-page map ${JSON.stringify(result.spreadPageMap)}`);
|
||||
}
|
||||
if (!result.pageCacheReady) failures.push('WebGL page cache is not ready');
|
||||
if (!result.pageCacheProbe?.stored || result.pageCacheProbe?.width !== 8 || result.pageCacheProbe?.height !== 8) {
|
||||
failures.push(`WebGL page cache probe failed: ${JSON.stringify(result.pageCacheProbe)}`);
|
||||
}
|
||||
if (result.grownBookState?.pageCount !== 310) failures.push(`expected page count to grow to 310 at writable limit, got ${result.grownBookState?.pageCount}`);
|
||||
if (!result.initialNavigationDisabled?.topBackward || !result.initialNavigationDisabled?.topFastBackward || !result.initialNavigationDisabled?.bottomStart || !result.initialNavigationDisabled?.bottomBack) {
|
||||
failures.push(`backward navigation should be disabled at first page: ${JSON.stringify(result.initialNavigationDisabled)}`);
|
||||
}
|
||||
if (result.finalSliderMax !== '310') failures.push(`expected final slider max 310, got ${result.finalSliderMax}`);
|
||||
if (result.finalMaxLabel !== '310') failures.push(`expected final max label 310, got ${result.finalMaxLabel}`);
|
||||
if (result.clampedSliderValue !== '10') failures.push(`expected slider clamp to written page 10, got ${result.clampedSliderValue}`);
|
||||
if (result.percentReserveState?.pageReserve !== 62) failures.push(`expected 20% reserve of 310 pages to be 62, got ${result.percentReserveState?.pageReserve}`);
|
||||
if (!result.overlayLayout?.pageLeftNoHorizontalScrollbar) failures.push('WebGL overlay page_left has a horizontal scrollbar');
|
||||
if (!result.overlayLayout?.choicesNoHorizontalScrollbar) failures.push('WebGL choices panel has a horizontal scrollbar');
|
||||
if (result.overlayLayout?.pageLeftOverflowX !== 'hidden') failures.push(`expected page_left overflow-x hidden, got ${result.overlayLayout?.pageLeftOverflowX}`);
|
||||
if (result.overlayLayout?.choicesOverflowX !== 'hidden') failures.push(`expected choices overflow-x hidden, got ${result.overlayLayout?.choicesOverflowX}`);
|
||||
if (!['none', 'absent'].includes(result.overlayLayout?.titleDisplay)) failures.push(`expected title hidden in WebGL overlay, got ${result.overlayLayout?.titleDisplay}`);
|
||||
if (!['none', 'absent'].includes(result.overlayLayout?.startPromptDisplay)) failures.push(`expected start prompt hidden in WebGL overlay, got ${result.overlayLayout?.startPromptDisplay}`);
|
||||
if (/^rgb\(0,\s*0,\s*0\)$/.test(result.overlayLayout?.buttonColor || '')) failures.push('choice button text is still black in WebGL overlay');
|
||||
if (!result.requestedFlip) failures.push('targeted page flip request was rejected');
|
||||
if (!result.targetFlipFinished) failures.push(`targeted page flip did not finish: ${JSON.stringify({
|
||||
requestedFlip: result.requestedFlip,
|
||||
activeFlipsAfterRequest: result.activeFlipsAfterRequest,
|
||||
activeFlipsAfterAdvance: result.activeFlipsAfterAdvance,
|
||||
postAdvanceState: result.postAdvanceState,
|
||||
eventDetail: result.targetFlipEventDetail
|
||||
})}`);
|
||||
if (result.postTargetFlipState?.spreadIndex !== 2) failures.push(`targeted page flip should commit spread 2, got ${result.postTargetFlipState?.spreadIndex}`);
|
||||
if (!result.endNavigationDisabled?.topForward || !result.endNavigationDisabled?.topFastForward || !result.endNavigationDisabled?.bottomForward || !result.endNavigationDisabled?.bottomEnd) {
|
||||
failures.push(`forward navigation should be disabled at written end: ${JSON.stringify(result.endNavigationDisabled)}`);
|
||||
}
|
||||
if (!result.textureInfo?.debug?.left?.painted || !result.textureInfo?.debug?.right?.painted) failures.push('page texture publish did not paint both pages');
|
||||
|
||||
if (failures.length) {
|
||||
console.error('WebGL runtime regression checks failed:');
|
||||
failures.forEach(failure => console.error(`- ${failure}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('WebGL runtime regression checks passed.');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ASSET_DIR = ROOT / "public" / "assets" / "webgl"
|
||||
RNG = random.Random(1780504860)
|
||||
|
||||
|
||||
def save_normal(path: Path, size: int = 2048) -> None:
|
||||
image = Image.new("RGB", (size, size))
|
||||
pixels = image.load()
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
grain = (
|
||||
math.sin(x * 0.028) * 8
|
||||
+ math.sin((x + y) * 0.011) * 5
|
||||
+ math.sin(x * 0.11 + y * 0.007) * 2
|
||||
)
|
||||
pore = math.sin(y * 0.12 + math.sin(x * 0.016) * 2.1) * 3 + (RNG.random() - 0.5) * 6
|
||||
pixels[x, y] = (
|
||||
max(0, min(255, round(128 + grain))),
|
||||
max(0, min(255, round(128 + pore))),
|
||||
255,
|
||||
)
|
||||
image.save(path)
|
||||
|
||||
|
||||
def save_dust(path: Path, size: int = 4096) -> None:
|
||||
image = Image.new("L", (size, size), 1)
|
||||
pixels = image.load()
|
||||
for y in range(size):
|
||||
ny = y / size
|
||||
for x in range(size):
|
||||
nx = x / size
|
||||
edge_dust = max(0, 1 - min(nx, ny, 1 - nx, 1 - ny) * 18)
|
||||
micro_noise = (RNG.random() ** 7.2) * 5.5
|
||||
fine_film = max(0, math.sin(nx * 21 + math.sin(ny * 11) * 0.7) - 0.988) * 3
|
||||
book_shelter = math.exp(-((nx - 0.5) * 2.7) ** 2 - ((ny - 0.5) * 1.8) ** 2) * 1.5
|
||||
value = min(255, 1 + edge_dust * 3 + micro_noise + fine_film + book_shelter)
|
||||
pixels[x, y] = round(value)
|
||||
|
||||
draw = ImageDraw.Draw(image, "L")
|
||||
for _ in range(1700):
|
||||
x = RNG.random() * size
|
||||
y = RNG.random() * size
|
||||
radius = 0.08 + RNG.random() * 0.22
|
||||
value = round(230 * 0.028)
|
||||
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=value)
|
||||
image.save(path)
|
||||
|
||||
|
||||
def soft_ellipse(draw: ImageDraw.ImageDraw, x: float, y: float, rx: float, ry: float, alpha: float) -> None:
|
||||
steps = 36
|
||||
for step in range(steps, 0, -1):
|
||||
scale = step / steps
|
||||
value = round(220 * alpha * (scale ** 2.1))
|
||||
draw.ellipse((x - rx * scale, y - ry * scale, x + rx * scale, y + ry * scale), fill=value)
|
||||
|
||||
|
||||
def save_grease(path: Path, size: int = 4096) -> None:
|
||||
image = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(image, "L")
|
||||
|
||||
for x, y, rx, ry, alpha in [
|
||||
(0.17, 0.38, 210, 88, 0.088),
|
||||
(0.83, 0.24, 170, 76, 0.081),
|
||||
(0.73, 0.76, 185, 78, 0.072),
|
||||
(0.5, 0.52, 300, 120, 0.053),
|
||||
]:
|
||||
soft_ellipse(draw, size * x, size * y, rx, ry, alpha)
|
||||
|
||||
for _ in range(8):
|
||||
x = size * (0.2 + RNG.random() * 0.62)
|
||||
y = size * (0.18 + RNG.random() * 0.64)
|
||||
rx = 36 + RNG.random() * 22
|
||||
ry = 14 + RNG.random() * 8
|
||||
alpha = 0.102 + RNG.random() * 0.042
|
||||
soft_ellipse(draw, x, y, rx, ry, alpha)
|
||||
for ridge in [i / 100 for i in range(-70, 71, 18)]:
|
||||
bbox = (
|
||||
x - rx * (0.26 + abs(ridge) * 0.58),
|
||||
y - ry * (0.2 + abs(ridge) * 0.5),
|
||||
x + rx * (0.26 + abs(ridge) * 0.58),
|
||||
y + ry * (0.2 + abs(ridge) * 0.5),
|
||||
)
|
||||
draw.arc(bbox, 15, 345, fill=round(245 * alpha * 0.42), width=2)
|
||||
|
||||
image.save(path)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ASSET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
save_normal(ASSET_DIR / "table_normal_2k.png")
|
||||
save_dust(ASSET_DIR / "table_dust_4k.png")
|
||||
save_grease(ASSET_DIR / "table_grease_4k.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,6 +10,8 @@ export interface GameMetadata {
|
||||
version?: string;
|
||||
copyright?: string;
|
||||
language?: string;
|
||||
bookPageCount?: number;
|
||||
pageReserve?: number;
|
||||
}
|
||||
|
||||
export interface GamePaths {
|
||||
|
||||
|
After Width: | Height: | Size: 637 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 445 KiB |
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 306 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 906 KiB |