106 Commits

Author SHA1 Message Date
Georg 8e87f935b8 Fix page label and restore nav code after bad clone-removal revert
The previous commit's clone removal corrupted the flip (shared live canvas overwritten mid-
animation -> flicker); reverting it fixed the flip but also discarded code that had ridden into
that commit and broke the page label. Recovering:

- Page label: the right-page pageNumber lookup returned null (meta not populated for the queried
  index) so every spread read 0. Now derive the printed number from the index (frontmatter
  pages 0-2 are unnumbered, so right-page index N prints as N-2), preferring the paginated
  pageNumber when present. Title still reads 0.
- Restored the manual-navigation-busy guard and the written-content navigation cap (no flipping
  forward into blank leaves before content exists; title stays on its own spread).

The flip flicker fix is the clone restoration in the prior revert; this restores the label and
navigation behavior on top of it. Suite 182. Flip-flicker and per-paragraph stutter still need
verification on a real (non-throttled) foreground tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:45:29 +02:00
Georg 7f60ce0d63 Revert "Remove per-draw canvas clones; title nav cap + right-page-number labels"
This reverts commit 0f66dae4eb.
2026-06-20 07:37:12 +02:00
Georg 0f66dae4eb Remove per-draw canvas clones; title nav cap + right-page-number labels
Two changes:

Eliminate cloning in the publish path. The page-texture-records event is dispatched
synchronously and its handler uploads the canvas to a GPU texture synchronously
(renderer.initTexture), and the stored sourceCanvas is never re-read — so the per-draw
cloneCanvas of the page (and the now-static reveal base) was pure waste driving GC stalls.
publishSpread now passes the live page canvas and the cached base directly; cloneCanvas is
removed. Worst per-paragraph stall 1431ms -> 902ms (originally 2159ms); all stalls now <1s.

Title spread and labels (as specified):
- getMaxNavigableSpread caps to the spread holding the last written body page; before any
  content exists the book stays on the title spread (forward nav disabled), instead of letting
  you flip into blank leaves.
- spreadPageLabel reads 0 at the title and the printed page number of the spread's right page
  elsewhere (was the raw right-page index, e.g. "3" before a game).

Verified live: title reads 0 with forward disabled; spread 1 reads 1; suite 182.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:33:21 +02:00
Georg c7364b0497 Cut per-paragraph GC stalls: reuse static paper base, cap lookahead to 1
Profiling the per-paragraph playback stutter showed the JS heap sawtoothing (37<->71MB) with
0.4-2.2s long tasks once per block — GC pauses from large (24-48MB) per-block canvas/ImageBitmap
allocations, not pagination (buildPages was ~29ms). These pauses freeze the flip/reveal
animation, which is also why the title flip looked un-animated.

- The reveal "base" layer is the plain paper background, identical for every page of a side.
  The worker now sends its bitmap once per side+size; the renderer caches the canvas and reuses
  it for all reveals, removing a large per-block bitmap+canvas allocation.
- WEBGL_BOOK_PREFETCH_LOOKAHEAD 2 -> 1 so only the next block's page render is prepared, instead
  of letting multiple large rasterizations overlap.

Verified live: per-paragraph long tasks roughly halved (10 -> 5 over the same window) and worst
case 2159ms -> 1431ms. Residual ~1.4s stall remains from the per-block page bitmap + prepared-
page snapshot clone + texture upload; further reduction needs reworking those to reuse buffers.
Suite 181.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:16:05 +02:00
Georg 91b5999cd2 Front-load worker fonts so a cold page render isn't cut short by the draw timeout
After clearing the page-texture cache, the worker's first drawSpread had to load the EB
Garamond faces AND rasterize inside a single 4s draw-timeout budget. On a cold load that could
exceed 4s, so the timeout fired, the draw resolved to null (no title painted), the loader then
completed over a black scene, and the title only appeared on a later render ("the image
returned outside the loader's progress indicators").

The renderer now awaits the worker's fonts-ready signal before its first timed draw (with a
15s safety cap so it can't hang), so font loading happens during the loader as its own step
rather than inside a draw's timeout window. Draw timeout raised 4s -> 6s for cold-render
headroom. Verified live: title page renders within the loader, no texture-worker-timeout
problems. Suite 178.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 07:00:22 +02:00
Georg 705d1ea6bf Fix new-game title flip + cap lookahead prepare burst
Builds on the worker migration with prepare-burst pacing and a title-flip fix:

- New game from mid-game left the book on the previous game's spread, so the first block's
  source and target spread matched and the title->content page turn was skipped. story:client-reset
  now returns the book to the title spread (spread 0) so the first block flips 0->1 and animates.
  Verified: requiresSpreadTransition src=0 tgt=1, page-flip-started/near-end fire.

- The lookahead burst-prepared many blocks at once, spiking allocation/GC into multi-second
  main-thread stalls. WebGL book prepares are now serialized through a chain and capped to a
  small lookahead window (TTS audio prefetch still spans the full window); future lookahead is
  also deferred until the current sentence has entered the display pipeline, keeping it off the
  first flip/reveal critical path. Worst game-start stall ~6s -> ~3.4s.

- Page flips now drive the scene through the sceneControl prewarm/startPreparedPageFlip API
  (awaited) instead of an event, and the scene awaits the async initial spread draw.

Suite 177. Remaining: a per-block prepare stall (~1.6-3.4s for large blocks at game start)
that profiling has not yet attributed to a single function (likely GC from prepare-path
allocation) — needs a DevTools performance capture for exact attribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 00:59:01 +02:00
Georg 004c077181 Don't recompute AO/shadow/reflection on page-texture content changes
handlePageTextureRecords() called markStaticSceneBuffersDirty() on every page-texture update,
forcing a full SSAO + shadow + reflection recompute (23-47ms frames) on every block during
playback — even though a page-text change moves no geometry. AO and shadows depend only on
geometry; the soft tabletop reflection picks up the new page on its throttled cadence. Removed
the forced dirty so only real geometry changes (flips, camera, rebuild, resize) recompute the
static buffers. Playback median ~60->63fps; the per-block forced heavy frames are gone (the
remaining periodic ~23ms frames are the normal 8Hz throttled refresh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:38:13 +02:00
Georg b0175b7cdc Harden worker page rendering: error/timeout recovery and awaited flip prewarm
Two robustness gaps from the worker migration, both raised in review:

- The raster worker had no failure recovery: a thrown createImageBitmap/font error or a
  dropped message would leave the draw promise pending forever, stalling the serialized draw
  chain and hanging prepare/playback. Added worker.onerror and a per-job timeout; both settle
  the in-flight draw to a logged miss (texture-worker-error / -timeout) so the pipeline
  degrades to last-good per the spec instead of hanging. A single settleRasterization path
  clears the timer and resolves.
- prepareSpreadTextureRecordsForFlip() called drawSpread() without awaiting it. That was safe
  when drawSpread was synchronous, but now that it is async (worker) the flip could race ahead
  of the worker draw and miss the resident texture. prewarmFlipTextures now awaits both spread
  draws before the resident-texture lookup.

Suite 168 (added assertions for worker error/timeout recovery and the awaited prewarm).
Normal draw path is behaviorally unchanged from the verified worker commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:29:20 +02:00
Georg 0e4d9e89d7 Move page rasterization to an OffscreenCanvas worker
Page text drawing (the bulk of drawSpread cost: layout, fonts, fillText across ~25 lines
x 2 pages at 3072px) ran synchronously on the main thread during prepare/lookahead, tanking
FPS at load and at flips/word boundaries.

New public/js/book-texture-worker.js owns rasterization off-thread: it loads the EB Garamond
faces via FontFace, draws base + title + lines + page number into an OffscreenCanvas, and
returns a full-page ImageBitmap plus a background-only base ImageBitmap (for the reveal mask)
per side. The main thread blits those onto the existing page canvases with one drawImage, so
the texture/reveal/scene pipeline downstream is unchanged. The worker also owns image loading
(fetch + createImageBitmap) and a DOM-free inline-tag parser (no document in a worker); the
renderer marshals the DOM-sourced title data in.

drawSpread is now async and serialized through a promise chain so the shared render state
(currentSpread, revealPublishBlockIds, spread override, reveal base) stays consistent across
the worker round trip even with concurrent lookahead prepares; the reveal context is passed
per draw rather than left on the instance. prepareRevealBlock / prepareContinuationRevealPlan /
preloadAdditionalRevealSpreads and their timeline callers await accordingly. The old
main-thread drawing methods are deleted (single implementation now lives in the worker).

Verified live: pages render correctly via the worker (text + drop caps crisp), worker fonts
load (probe returns fonts-ready + drawn), idle ~66fps, playback median ~60fps. Remaining
non-rasterization main-thread costs (procedural texture generation in the loader; pagination
text layout; per-frame reflection/shadow on content change) are separate follow-ups. Suite 166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:09:34 +02:00
Georg 97f0b913be Cursor reflects game state over the 3D scene again
Two regressions made the cursor stop communicating game state:

- The canvas had a hardcoded `cursor: grab`, overriding the document-level process-state
  cursor everywhere over the 3D scene (always a hand). Removed it so the canvas inherits the
  state cursor; grab is now shown only transiently while right-drag-rotating the camera.

- normalizeProcessState pinned ready/waiting-generating to the playing (feather) cursor
  whenever playbackCoordinator.isPlaying was set, which lingered at choice prompts — so an
  open choice showed the feather instead of the input cursor. Now, when an input prompt is
  open AND no sentence is actively playing (timeline's webglBookPlaybackActive), the playback
  overlay is stripped (playing-ready->ready, playing-generating->waiting-generating) and the
  input/server cursor shows. Opening an input mode also refreshes the cursor immediately.

Verified live over the canvas: feather while a sentence plays, input arrow at a choice/idle,
and they switch correctly with playback state (no stuck feather, no constant grab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:00:49 +02:00
Georg 0e3e2abdb6 Front-load post-processing compile into the loader
primeSceneForLoader() compiled scene materials and rendered the shadow/reflection passes
once, but never ran the full composer pipeline — so the SSAO and output passes compiled
their programs and allocated their render targets on the first live frame after the loader
faded, tanking FPS for ~1-2s before it climbed to full.

Now the loader runs composer.render() twice during prime, and precompiles the flip page
materials (created lazily on first flip, so previously missed by renderer.compile) via a
throwaway probe mesh. The heavy first-frame work is paid behind the loader overlay instead.

Verified live: loader timings show composerWarmup taking ~1499ms during load (exactly the
cost that used to hit the first frame); after fade-out there are no over-budget tank frames
in the slow-frame log and idle settles at ~72fps. Static suite passes (165).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:45:57 +02:00
Georg b180637ea7 Hold 60fps: throttle shadow/reflection passes when book geometry is static
The table reflection (full scene re-render) and the book shadow maps each cost ~11ms/frame
and were refreshing at 30Hz even when nothing moved, so every idle/reveal frame paid for one
heavy pass on top of the ~12ms scene render — ~45-52fps.

These passes only need full-rate updates while the book geometry is actually moving (a page
flip). At idle, or during a text reveal where only the page texture mask animates, they now
refresh at 8Hz (candle flicker is the only thing changing them then, captured imperceptibly).
Most non-flip frames are then just the scene render.

pixelRatio is deliberately left at 2x: the book is tilted, so page glyphs are minified along
the tilt and the supersampling is the scene's antialiasing (the composer MSAA is disabled in
app-integration mode). Reducing it blurs text and exposes edge aliasing, so 60fps is bought
from the geometry-independent passes instead. Expressed pixelRatio via devicePixelRatio so it
stays native on HiDPI.

Verified live at WQHD/2x (screenshot-checked crisp text + clean edges): idle ~64fps median
(was 52), reveal ~66fps median (was ~33). Remaining single-digit dips are main-thread page
rasterization during background prepare — addressed by the worker migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:39:29 +02:00
Georg 1e8defbb55 Spanning playback: one background-prepared path, no fallbacks
For a block that overflows onto the next spread, the plan is now prepared spanning-aware
during the background lookahead — the start spread's reveal timing is derived across both
preview spreads, and the continuation spread's plan is prepared and cached at the same time.

playback then follows a single path:
- activate reuses the prepared start plan (removed the synchronous forceRebuild rebuild).
- revealContinuationSpread reuses the prepared continuation plan (removed the redraw
  fallback); a missing plan is surfaced as a problem, not silently redrawn.

This removes the parallel/immediate prepare distinction and the two fallbacks, leaving one
intended path, and moves the spanning draw work off the critical path.

Verified live on a real spanning block: right line reveals at its area share (~3.3s), the
flip fires, and the continuation appears ~0.3s after the flip (was ~2.7s) and animates
progressively across the next spread over the full TTS — no pop-in, no fast-forward, no
timeline-reveal-continuation-missing. Static suite passes (165).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:24:13 +02:00
Georg 28d5e51c92 Reuse spanning-aware prepared plan at activate (kill pre-playback pause)
A spanning block stalled ~2.1s before it began playing: activate ran prepareRevealBlock
with forceRebuild, synchronously redrawing the start spread (and preloading the
continuation spread) on the main thread, because the lookahead plan had been built with
right-only timing before pagination committed the overflow.

Build the start-spread plan spanning-aware during lookahead instead: when the preview
layout shows the block overflows, derive its timing across both preview spreads (via the
revealSpreadSourceOverride) and cache it. activate then reuses that plan — the same fast
cached-plan path non-spanning blocks already use — with no synchronous redraw. forceRebuild
is kept only as a fallback when a block spans but was not prepared spanning-aware (e.g. an
immediate prepare with no preview layout), and an evicted plan still rebuilds correctly
because pagination is committed by then.

Verified live: the spanning block's pre-playback gap dropped from ~2088ms to 139ms (equal
to non-spanning blocks), while the right line still reveals over its area share (~3.3s),
the continuation still animates from the start, and there are no fast-forwards or problems.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:10:25 +02:00
Georg 47af10d60c Prepare spanning-block continuation spread in background (kill post-flip redraw)
For a paragraph that overflows onto the next spread, the continuation page was redrawn
synchronously after the flip (drawSpread on the main thread), so the next page stayed
blank for ~2.7s and then the carried-over lines popped in already ~24% revealed instead
of animating from the start.

Move that work off the critical path: during lookahead, prepare and cache the
continuation spread's reveal plan using the not-yet-committed preview spreads (so per-line
timing is computed across both spreads), then reuse it after the flip instead of redrawing.

- pagination: expose the preview spread layout on the returned preview spread so the owner
  can detect the continuation spread (race-free; each call owns its preparedSpreads).
- renderer: revealSpreadSourceOverride lets region collection use preview spreads during
  lookahead; prepareContinuationRevealPlan draws+caches the continuation plan (publishEvent
  off); takeContinuationRevealPlan reuses it, re-stamped as an activate-phase publish.
- timeline: prepare the continuation plan during background (non-immediate) prepares;
  revealContinuationSpread reuses it, falling back to the redraw when none was prepared.

Verified live on a spanning block: continuation now appears ~0.25s after the flip (was
~2.7s) at ve~3471 = the right line's duration, i.e. it animates from the start (no pop-in),
runs to ~full over the TTS, no fast-forward, no continuation-missing problems.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:04:21 +02:00
Georg e72594b3ff Revert per-line reveal timing to area-weighting
The previous commit changed per-line reveal-duration distribution from ink-area to
word-count. That dropped a deliberate precision decision (area gives sub-line
granularity) and, verified live on a spanning paragraph, it was what made the
continuation page fail to animate. Restore area-weighting for the per-line split.

The word-share scaling of the *total* duration for partial (spanning) blocks and the
timeline-module timing snapshot/restore are kept — they only preserve existing
word-timings, they do not change the area-based per-line distribution.

Verified: on a real spanning block the right line reveals over its area share (~3.3s),
the page flips, and the continuation animates progressively across the next spread
over the full TTS (no fast-forward, no reveal-all-at-once).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:45:26 +02:00
Georg dc2afcf831 Fix spanning-paragraph reveal pacing (right page no longer consumes full TTS)
A paragraph that overflows the right page onto the next spread revealed its single
right-page line over the entire TTS, then timed out (timeline-reveal-commit-timeout)
and only flipped after the whole narration. Two root causes:

- At activate the reused lookahead segment played a sentence instance whose animation
  word-timings were lost (wordTimings=[], totalDuration=0), so reveal timing fell back
  to an area estimate spanning the full TTS. Snapshot the timings at prepare and restore
  them at activate.
- Reveal duration was distributed by ink area, but just-paginated continuation lines
  have ~0 area, so the one right-page line received the whole duration. Distribute by
  word count (reliable) with area as fallback.

Now the right page reveals only its word share (~2.7s for a 6/55-word line), commits,
and flips while TTS continues; the continuation animates on the next spread. Also
rewrote the right-reveal wait to a single timer + commit/fast-forward listeners with
cleanup, removing the stray timeline-reveal-commit-timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:13:39 +02:00
Georg 6bd1f45362 Reset per-game reveal state on new game so reveal animates over cached content
Starting a new game reuses block ids (1,2,3...). The reveal clock's per-block
start times (activeRevealBlockStarts in the lab) and the renderer's animation/
revealed sets are keyed by block id and were never cleared on a client reset, so
a new game over already-cached content inherited the previous run's start times.
beginPageReveal then computed a huge elapsed and the shader treated the reveal as
already complete — showing everything at once instead of animating.

resetClientPlaybackAndDisplay (run on new game and restore) now emits
story:client-reset; the lab clears activeRevealBlockStarts/pending reveal state,
the texture renderer clears active animations and revealed-block ids, and the
timeline invalidates prepared segments. So each game starts with a clean reveal
clock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:40:31 +02:00
Georg a845108c43 Make WebGL book navigation spread-based and clear stale flip reveal mask
- Navigation now operates on spread indices, not the non-contiguous page-position
  scheme that mapped a forward step onto the same spread (so forward stalled and
  triggered a no-op multi-flip). Forward/back move one spread; start/end and the
  slider use spread indices. The page readout shows the odd page of the visible
  pair (2*spread+1) or 0 at the title spread.
- Flipping forward could show the source page with its last word still masked: a
  stale reveal mask left on the flip surface by a previous playback flip was not
  cleared when the (finished) source page had no active reveal. Reset the flip
  surface reveal shader in that case so the full page shows during the turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:27:18 +02:00
Georg ab194062bb Fix WebGL reveal pacing on spanning pages and page-reveal-on-flip
- Reveal timing is now word-proportional per page: when a block's reveal only
  covers part of the block (the continuation spread is not paginated at reveal
  time), the page reveals only its share of the TTS, offset by the words before
  it. The right page no longer absorbs the whole TTS before flipping; it flips at
  normal pace and the continuation resumes on the next spread while TTS plays. No
  effect when the regions already cover the whole block (unified plan / one page).
- Page flip start now shows the target spread's same-side page beneath the lifting
  page (revealed as it turns away) instead of a blank that pops in after the flip.
  Deferred (pending-reveal) sides stay blank so the masked reveal still lands via
  activate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:01:41 +02:00
Georg 8bb18fa201 Rework WebGL book playback to single ownership and fix flip/reveal pipeline
Establish book-playback-timeline as the sole playback owner driving the
scene through formal webgl-book:* events (not the BookLabDebug surface),
with a single reveal clock in the scene render loop and webgl-page-cache as
the only texture cache. Remove the legacy dual playback path and the
ownsPageFlipCommit gating.

Fixes:
- Flip page detached/folded at the spine: restore the raw page-cap line for
  flip geometry (matches the prototype/pre-regression), removing
  normalizeFlipLineToVisiblePage which moved the pivot off the spine arc.
- Flip textures: distance-based UVs (no horizontal compression),
  direction-aware face material (source on the camera-facing side), source
  meta derived from the visible spread (manual flips), prewarm shape fix.
- Reveal: flash removed on the static page and the flip back surface;
  spanning blocks rebuild the reveal plan at activate and continue the
  reveal on the next spread after the fill flip.
- Cache staleness is contentVersion-primary; nav clamps to spreadCount.

Docs updated to describe the intended single-owner architecture. Regression
checks updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:30:12 +02:00
Georg c19ebe3089 Stabilize WebGL title and timeline texture flow 2026-06-17 08:31:46 +02:00
Georg ef358c5cfd Stabilize WebGL flip reveal handoff 2026-06-10 15:10:57 +02:00
Georg 97eab216b7 Fix WebGL reveal timing and flip texture readiness 2026-06-10 13:54:54 +02:00
Georg e3d66686b9 Fix WebGL page readiness gating 2026-06-10 10:46:01 +02:00
Georg 623b42caf9 Fix WebGL timeline startup ordering 2026-06-10 10:04:06 +02:00
Georg ce8147b5b1 Enforce explicit WebGL book playback timeline 2026-06-10 09:35:00 +02:00
Georg 5a84923884 Restore WebGL reveal timing diagnostics 2026-06-10 08:09:02 +02:00
Georg 10bf23b10b Add timeline owner for WebGL book playback 2026-06-10 02:00:57 +02:00
Georg b41340151d Checkpoint WebGL book playback refactor state 2026-06-10 01:07:22 +02:00
Georg 171cafeb65 Stabilize WebGL book pagination restore 2026-06-09 16:42:12 +02:00
Georg fe51410a3b Fix WebGL reveal timing and flip prewarm 2026-06-09 10:05:23 +02:00
Georg d665a0f237 Fix WebGL line reveal renderer 2026-06-09 09:02:54 +02:00
Georg 419691000c Fix WebGL page cache and flip sequencing 2026-06-08 23:08:13 +02:00
Georg a73dc5725f Add WebGL page cache and runtime checks 2026-06-08 14:39:42 +02:00
Georg 119cefd4bd Fix WebGL page number texture crash 2026-06-08 10:34:20 +02:00
Georg efd1e6cfff Implement WebGL page reserve navigation 2026-06-08 10:25:54 +02:00
Georg 3e28d7db23 Checkpoint before WebGL page reserve sprint 2026-06-08 09:42:59 +02:00
Georg 86b6fa0419 Implement WebGL book spread flip groundwork 2026-06-08 09:13:37 +02:00
Georg c86a304364 Checkpoint WebGL book reveal optimization 2026-06-08 08:19:20 +02:00
Georg 7abd3387f3 Correct WebGL dropcap texture layout 2026-06-07 17:59:01 +02:00
Georg da37608197 Reduce WebGL page texture runtime stalls 2026-06-07 17:37:31 +02:00
Georg 53c24e4fae Stabilize WebGL reveal timing 2026-06-07 16:42:09 +02:00
Georg 9695d48368 Checkpoint WebGL font gating 2026-06-07 14:49:05 +02:00
Georg 74ddd1de1c Gate WebGL book texture fonts 2026-06-07 14:35:00 +02:00
Georg 9434950826 Queue WebGL book reveal masks 2026-06-07 13:52:07 +02:00
Georg 7fc083fb58 Add shader page reveal checkpoint 2026-06-07 13:10:17 +02:00
Georg 7725ce9c73 Soften WebGL paper rendering 2026-06-07 12:22:26 +02:00
Georg de81a7c5c5 Stage WebGL scene loading 2026-06-07 12:08:13 +02:00
Georg 1b593c8c7b Restore WebGL book quality settings 2026-06-07 11:13:05 +02:00
Georg 777e39a650 Correct WebGL book page projection 2026-06-07 09:56:56 +02:00
Georg 081cfa9902 Optimize WebGL book texture reveal 2026-06-06 16:44:15 +02:00
Georg 1b8c8f8bce Add texture drop cap pagination 2026-06-06 15:39:53 +02:00
Georg 431e305df9 Add WebGL FPS cap and texture word reveal 2026-06-06 15:37:44 +02:00
Georg bc736513d4 Restore WebGL control overlay and page grid 2026-06-06 15:17:50 +02:00
Georg 9836c68ffa Add texture-space book pagination foundation 2026-06-06 14:58:25 +02:00
Georg 62215b280f Start texture-space book renderer 2026-06-06 14:51:07 +02:00
Georg 326f812b22 Begin 3D canvas book renderer migration 2026-06-06 14:42:49 +02:00
Georg b734d83227 Checkpoint WebGL book renderer work 2026-06-06 14:35:37 +02:00
Georg 83ca095d54 Document WebGL page texture pipeline 2026-06-06 11:24:50 +02:00
Georg 0cb1e7c6f5 Fine tune WebGL book indirect lighting 2026-06-06 10:55:49 +02:00
Georg 965be72ea4 Tune WebGL book bounce lighting 2026-06-06 10:41:00 +02:00
Georg 0956d2ef1f Add WebGL book headbands and bounce lighting 2026-06-06 10:29:18 +02:00
Georg 925caa57bb Refine WebGL paper and spine materials 2026-06-06 08:53:29 +02:00
Georg 67c0c4e7e3 Add WebGL cloth and paper materials 2026-06-06 08:03:45 +02:00
Georg 13f8b60e20 Improve WebGL leather material 2026-06-06 03:00:07 +02:00
Georg f634500121 Round WebGL book cover edges 2026-06-06 02:48:57 +02:00
Georg 874d360d22 Fix WebGL right page content layout 2026-06-06 01:28:42 +02:00
Georg 32d2a6a15a Fix WebGL book SSAO occlusion 2026-06-06 01:07:13 +02:00
Georg 83b30000da Checkpoint WebGL page and mirror debug fixes 2026-06-06 00:54:42 +02:00
Georg ca38f9ce92 Checkpoint WebGL procedural book lab 2026-06-05 22:51:30 +02:00
Georg 80d29ed2d2 Set book page bounds and physical cover width 2026-06-05 17:19:40 +02:00
Georg f00072282e Checkpoint arclength page flip width 2026-06-05 16:55:42 +02:00
Georg be1056b280 Allow thin-book spine to shrink 2026-06-05 16:42:13 +02:00
Georg 738e683c7b Checkpoint fore-edge direction fix 2026-06-05 16:32:16 +02:00
Georg ee14916661 Checkpoint deterministic page support 2026-06-05 16:22:12 +02:00
Georg e88ab8c48b Checkpoint variable page segment lengths 2026-06-05 15:39:18 +02:00
Georg fd608ba217 Checkpoint cover segment legality 2026-06-05 15:15:47 +02:00
Georg ac382a6cac Checkpoint shared cover support profile 2026-06-05 14:52:00 +02:00
Georg a92822bc44 Checkpoint book shape task 2 2026-06-05 14:37:22 +02:00
Georg 139086917e Checkpoint cover frame overhang 2026-06-05 14:09:15 +02:00
Georg 9f659f8f63 Checkpoint restored book coordinate frame 2026-06-05 14:04:17 +02:00
Georg ecc4413014 Checkpoint before book geometry fixes 2026-06-05 13:13:00 +02:00
Georg 65dbbdd093 Checkpoint hinge-relative book geometry 2026-06-05 12:39:23 +02:00
Georg fc38dca7cf Checkpoint max page cover geometry 2026-06-05 12:06:38 +02:00
Georg 467842ba0b Checkpoint flip page dimensions 2026-06-05 11:48:01 +02:00
Georg b5c2f9fa42 Checkpoint packed spine spacing 2026-06-05 11:32:32 +02:00
Georg ee641d2b91 Texture procedural page stack lines 2026-06-05 04:08:11 +02:00
Georg ae84eb8976 Add burst page flip controls 2026-06-05 03:41:26 +02:00
Georg ae8068ad8a Checkpoint page flip surface 2026-06-05 03:32:50 +02:00
Georg 44fb461eae Checkpoint open-bottom page stack 2026-06-05 02:19:00 +02:00
Georg 444312351a Checkpoint endpoint page bodies 2026-06-05 02:05:35 +02:00
Georg 248973fc77 Checkpoint page support solver 2026-06-05 01:53:11 +02:00
Georg 5283f0007e Checkpoint optimized book shape spline 2026-06-05 01:03:27 +02:00
Georg 5a5464e0b4 Checkpoint accepted book shape solver state 2026-06-05 00:33:41 +02:00
Georg a95ac9db50 Checkpoint reconstructed book shape solver 2026-06-05 00:31:48 +02:00
Georg 4adf85b4d2 Checkpoint curve-origin book shape lab 2026-06-04 23:39:44 +02:00
Georg 073be20dca Checkpoint clean procedural book profile 2026-06-04 23:03:33 +02:00
Georg 552bf14626 Checkpoint procedural book shape lab 2026-06-04 22:13:06 +02:00
Georg e5b00f7472 Stabilize WebGL lighting lab 2026-06-04 20:43:00 +02:00
Georg 444acb6229 Refine WebGL table surface contamination 2026-06-04 13:37:41 +02:00
Georg e1396d44bb Improve planar reflection alignment 2026-06-04 12:11:18 +02:00
Georg 90308e4b1b Preserve reflection render target aspect 2026-06-04 11:54:59 +02:00
Georg 5127bbc743 Improve WebGL reflection and texture quality 2026-06-04 11:50:25 +02:00
Georg bdec4590d2 Add table shader diagnostics and candle shadow model 2026-06-04 11:40:55 +02:00
Georg 199462442c Add WebGL book scene checkpoint 2026-06-04 11:10:48 +02:00
73 changed files with 17545 additions and 76 deletions
+2
View File
@@ -6,6 +6,8 @@ export interface GameMetadata {
version?: string; version?: string;
copyright?: string; copyright?: string;
language?: string; language?: string;
bookPageCount?: number;
pageReserve?: number;
} }
export interface GamePaths { export interface GamePaths {
mainGameFile: string; mainGameFile: string;
+1 -1
View File
@@ -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"}
+713
View File
@@ -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.
+48
View File
@@ -32,6 +32,7 @@
"eslint": "^9.23.0", "eslint": "^9.23.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"playwright": "^1.60.0",
"ts-jest": "^29.3.1", "ts-jest": "^29.3.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.2" "typescript": "^5.8.2"
@@ -6266,6 +6267,53 @@
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+4
View File
@@ -40,6 +40,9 @@
"pretest-server": "npm run check:node", "pretest-server": "npm run check:node",
"test-server": "ts-node src/test-server-yaml.ts", "test-server": "ts-node src/test-server-yaml.ts",
"build": "tsc", "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", "test": "jest",
"lint": "eslint --ext .ts src/", "lint": "eslint --ext .ts src/",
"lint:fix": "eslint --ext .ts src/ --fix" "lint:fix": "eslint --ext .ts src/ --fix"
@@ -61,6 +64,7 @@
"eslint": "^9.23.0", "eslint": "^9.23.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"playwright": "^1.60.0",
"ts-jest": "^29.3.1", "ts-jest": "^29.3.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.2" "typescript": "^5.8.2"
+37
View File
@@ -0,0 +1,37 @@
WebGL Scene Assets
==================
- `wood_table_diff_1k.jpg`
- Source: Poly Haven, "Wood Table"
- URL: https://polyhaven.com/a/wood_table
- Author: Dimitrios Savva
- License: CC0
- `book/open-book-poly-pizza.glb`
- Source: Poly Pizza, "open book"
- URL: https://poly.pizza/m/4WPcl72i1_S
- Author: Justin Randall
- License: Creative Commons Attribution 3.0
- Notes: Real authored open-book GLB used as the visible book model.
- `book/open-book-poly-pizza-preview.jpg`
- Source: Poly Pizza, "open book" preview image
- URL: https://poly.pizza/m/4WPcl72i1_S
- Author: Justin Randall
- License: Creative Commons Attribution 3.0
Candidate model found during follow-up search:
- "Old Magical Book" by Akiko.Tomiyoshi on Sketchfab
- URL: https://sketchfab.com/3d-models/old-magical-book-326cf7653c7c4ec19d2672f5a7a33578
- License: Creative Commons Attribution
- Notes: The model description says it has page-flipping animation using bone and lattice modifiers.
- Blocker: Sketchfab's download API requires authenticated credentials, so it was not pulled into the repository automatically.
Candidate model checked and rejected for automated import:
- "Rigged book" by Jissse on Blend Swap
- URL: https://blendswap.com/blend/26504
- License: CC0
- Notes: The page lists a Blender rig with controllable pages.
- Blocker: The file download requires signing in; the unauthenticated response is a download page, not model bytes.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

+320
View File
@@ -1926,3 +1926,323 @@ body:not([data-game-running="true"]) #start_prompt {
.openai-setting { .openai-setting {
display: none; /* Hidden by default, shown when the relevant provider is selected */ display: none; /* Hidden by default, shown when the relevant provider is selected */
} }
body.webgl-mode {
background: #090705;
align-items: stretch;
justify-content: stretch;
--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;
}
}
+1 -1
View File
@@ -280,6 +280,6 @@
console.log(message); console.log(message);
}; };
</script> </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> </body>
</html> </html>
+174
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+891
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+370
View File
@@ -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' }));
};
+7 -1
View File
@@ -20,6 +20,7 @@ class ChoiceDisplayModule extends BaseModule {
this.currentTurnId = 0; this.currentTurnId = 0;
this.autoTurnCounter = 0; this.autoTurnCounter = 0;
this.lastAutoTurn = new Map(); this.lastAutoTurn = new Map();
this.selectionInProgress = false;
this.template = { this.template = {
cells: { cells: {
default: { default: {
@@ -136,6 +137,7 @@ class ChoiceDisplayModule extends BaseModule {
}; };
this.currentGlossaryEntries = detail.glossaryEntries; this.currentGlossaryEntries = detail.glossaryEntries;
this.choices = this.normalizeChoices(detail.choices); this.choices = this.normalizeChoices(detail.choices);
this.selectionInProgress = false;
this.render(); this.render();
} }
@@ -159,7 +161,7 @@ class ChoiceDisplayModule extends BaseModule {
return; 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; return;
} }
@@ -434,6 +436,9 @@ class ChoiceDisplayModule extends BaseModule {
} }
async selectChoice(index) { async selectChoice(index) {
if (this.selectionInProgress) {
return;
}
if (!this.socketClient) { if (!this.socketClient) {
this.socketClient = this.getModule('socket-client'); this.socketClient = this.getModule('socket-client');
} }
@@ -442,6 +447,7 @@ class ChoiceDisplayModule extends BaseModule {
return; return;
} }
this.selectionInProgress = true;
this.clear(); this.clear();
document.dispatchEvent(new CustomEvent('story:process-state', { document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index } detail: { state: 'command-waiting', reason: 'choice-selected', choiceIndex: index }
+31 -5
View File
@@ -54,7 +54,9 @@ class GameLoopModule extends BaseModule {
'requestStartGame', 'requestStartGame',
'requestSaveGame', 'requestSaveGame',
'requestLoadGame', 'requestLoadGame',
'resetClientPlaybackAndDisplay' 'resetClientPlaybackAndDisplay',
'getWebGLBookState',
'applyWebGLBookState'
]); ]);
} }
@@ -74,7 +76,7 @@ class GameLoopModule extends BaseModule {
return true; return true;
} }
start() { async start() {
console.log("GameLoop: Starting game sequence..."); console.log("GameLoop: Starting game sequence...");
try { try {
@@ -85,12 +87,14 @@ class GameLoopModule extends BaseModule {
console.log("GameLoop: Setting up socket listeners and connecting..."); console.log("GameLoop: Setting up socket listeners and connecting...");
// Set up socket event listeners and connect // Set up socket event listeners and connect
this.setupSocketEventListeners(); const connected = await this.setupSocketEventListeners();
// Set the game loop as running // Set the game loop as running
this.isRunning = true; this.isRunning = true;
return connected;
} catch (error) { } catch (error) {
console.error("Error starting game loop:", error); console.error("Error starting game loop:", error);
return false;
} }
} }
@@ -131,7 +135,7 @@ class GameLoopModule extends BaseModule {
if (!socketClient) { if (!socketClient) {
console.error("Socket client module not found"); console.error("Socket client module not found");
return; return Promise.resolve(false);
} }
// Connect UI controller to socket client for command handling // Connect UI controller to socket client for command handling
@@ -179,12 +183,13 @@ class GameLoopModule extends BaseModule {
}); });
// Connect to the socket server // Connect to the socket server
socketClient.connect().then(success => { return socketClient.connect().then(success => {
if (success) { if (success) {
console.log("GameLoop: Socket connection established successfully."); console.log("GameLoop: Socket connection established successfully.");
} else { } else {
console.error("GameLoop: Failed to connect to socket server"); console.error("GameLoop: Failed to connect to socket server");
} }
return success;
}); });
} }
@@ -322,6 +327,7 @@ class GameLoopModule extends BaseModule {
if (typeof storyHistory.saveSlot === 'function') { if (typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(this.autoSaveSlot, { await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: null, inkState: null,
webglBookState: this.getWebGLBookState(),
choices: [], choices: [],
inputMode: 'none', inputMode: 'none',
running: false running: false
@@ -347,6 +353,7 @@ class GameLoopModule extends BaseModule {
if (!isCurrentOperation()) return; if (!isCurrentOperation()) return;
await storyHistory.saveSlot(this.autoSaveSlot, { await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: response.savedState, inkState: response.savedState,
webglBookState: this.getWebGLBookState(),
choices: [], choices: [],
inputMode: 'none', inputMode: 'none',
running: true running: true
@@ -372,6 +379,7 @@ class GameLoopModule extends BaseModule {
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0, latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0, renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null, musicState: audioManager?.getMusicState?.() || null,
webglBookState: this.getWebGLBookState(),
choices: this.currentChoices, choices: this.currentChoices,
inputMode: this.currentInputMode, inputMode: this.currentInputMode,
running: this.gameState.started && !this.gameState.ended running: this.gameState.started && !this.gameState.ended
@@ -453,6 +461,7 @@ class GameLoopModule extends BaseModule {
browserSave.renderedLineCount || 0 browserSave.renderedLineCount || 0
); );
} }
this.applyWebGLBookState(browserSave.webglBookState);
const uiController = this.getModule('ui-controller'); const uiController = this.getModule('ui-controller');
if (browserSave && uiController?.displayHandler?.restoreFromHistory) { if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
await uiController.displayHandler.restoreFromHistory(browserSave); 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) { hasUnrenderedHistory(browserSave) {
return Boolean(browserSave) && return Boolean(browserSave) &&
Number(browserSave.latestBlockId || 0) > Number(browserSave.latestRenderedBlockId || 0); Number(browserSave.latestBlockId || 0) > Number(browserSave.latestRenderedBlockId || 0);
@@ -565,6 +583,7 @@ class GameLoopModule extends BaseModule {
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0, latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0, renderedLineCount: storyHistory.renderedLineCount || 0,
musicState: audioManager?.getMusicState?.() || null, musicState: audioManager?.getMusicState?.() || null,
webglBookState: this.getWebGLBookState(),
choices: this.currentChoices, choices: this.currentChoices,
inputMode: this.currentInputMode, inputMode: this.currentInputMode,
running: this.gameState.started && !this.gameState.ended running: this.gameState.started && !this.gameState.ended
@@ -638,6 +657,13 @@ class GameLoopModule extends BaseModule {
if (inputHandler && typeof inputHandler.clearHistory === 'function') { if (inputHandler && typeof inputHandler.clearHistory === 'function') {
inputHandler.clearHistory(); 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' }
}));
} }
} }
+21 -9
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR' ERROR: 'ERROR'
}; };
const MODULE_CACHE_BUSTER = '20260516-scroll-window'; const MODULE_CACHE_BUSTER = '20260610-book-timeline-l';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; 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: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 },
{ id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 },
{ id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module
{ id: '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: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 },
{ id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS { id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS
{ id: 'book-playback-timeline', script: '/js/book-playback-timeline-module.js', weight: 8 },
// Audio and TTS modules // Audio and TTS modules
{ id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 }, { id: 'audio-manager', script: '/js/audio-manager-module.js', weight: 12 },
@@ -824,17 +830,17 @@ const ModuleLoader = (function() {
async function completeFinalization() { async function completeFinalization() {
isLoadingComplete = true; isLoadingComplete = true;
// Call the start method on the game loop module directly // Call the start method on the game loop module directly.
// Ensure the game loop module was found during initialization // 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') { 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 { try {
gameLoopModule.start(); console.log("Loader: Starting Game Loop before hiding overlay.");
await gameLoopModule.start();
} catch (error) { } catch (error) {
console.error("Error starting Game Loop:", error); console.error("Error starting Game Loop:", error);
} }
await hideOverlay();
} else { } else {
console.error("Loader: Game Loop module not found or start method missing."); console.error("Loader: Game Loop module not found or start method missing.");
// Hide overlay anyway, but log error // Hide overlay anyway, but log error
@@ -883,12 +889,18 @@ const ModuleLoader = (function() {
return; return;
} }
await waitForProgressIndicatorsToExit(); await Promise.race([
waitForProgressIndicatorsToExit(),
new Promise(resolve => setTimeout(resolve, 700))
]);
// Set opacity to 0 to trigger the fade-out transition // Set opacity to 0 to trigger the fade-out transition
loadingOverlay.style.opacity = '0'; loadingOverlay.style.opacity = '0';
await waitForTransition(loadingOverlay, 'opacity'); await Promise.race([
waitForTransition(loadingOverlay, 'opacity'),
new Promise(resolve => setTimeout(resolve, 700))
]);
console.log('Module Loader: Removing overlay from DOM'); console.log('Module Loader: Removing overlay from DOM');
+29 -3
View File
@@ -24,6 +24,7 @@ class MarkupParserModule extends BaseModule {
'parseImageOptions', 'parseImageOptions',
'parseSfxOptions', 'parseSfxOptions',
'parseMusicOptions', 'parseMusicOptions',
'parsePageReserveDirective',
'markdownToHtml', 'markdownToHtml',
'markdownToPlainText', 'markdownToPlainText',
'smartypants', 'smartypants',
@@ -89,7 +90,7 @@ class MarkupParserModule extends BaseModule {
const lower = token.toLowerCase(); const lower = token.toLowerCase();
const [key, value] = lower.split('='); 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; options.size = lower === 'widescreen' ? 'landscape' : lower;
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) { } else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
const seconds = Number(value); const seconds = Number(value);
@@ -178,11 +179,14 @@ class MarkupParserModule extends BaseModule {
} }
parseParagraph(rawText) { 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 { return {
text: this.markdownToPlainText(inline.text), text: this.markdownToPlainText(inline.text),
layoutText: this.markdownToHtml(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, layoutText: paragraph.layoutText,
cueMarkers: paragraph.cueMarkers, cueMarkers: paragraph.cueMarkers,
role, role,
metadata: {
...(paragraph.pageReserve ? { pageReserve: paragraph.pageReserve } : {})
},
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first', isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
dropCap: role === 'chapter-first', dropCap: role === 'chapter-first',
addTopSpace: role === 'textblock-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) { parseInline(text) {
return { return {
text: String(text || '').replace(/\s{2,}/g, ' ').trim(), text: String(text || '').replace(/\s{2,}/g, ' ').trim(),
+128 -1
View File
@@ -50,10 +50,14 @@ class OptionsUIModule extends BaseModule {
'setupApiUrlFields', 'setupApiUrlFields',
'setupInitialState', 'setupInitialState',
'dispatchApiChangeEvent', 'dispatchApiChangeEvent',
'getMetadataNumber',
'hasFixedBookPageCount',
'hasFixedPageReserve',
'getPreference', 'getPreference',
'updatePreference', 'updatePreference',
'updateUIText', '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 * Gets a preference from the persistence manager
* @param {string} category - Preference category * @param {string} category - Preference category
@@ -250,6 +273,90 @@ class OptionsUIModule extends BaseModule {
body.appendChild(appSettingsSection); 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 // TTS Section
const ttsSection = document.createElement('div'); const ttsSection = document.createElement('div');
ttsSection.className = 'options-section'; ttsSection.className = 'options-section';
@@ -1020,6 +1127,7 @@ class OptionsUIModule extends BaseModule {
console.log('Options UI: Preference bindings set up', this.bindings.length); console.log('Options UI: Preference bindings set up', this.bindings.length);
this.updateSpeedDisplay(); this.updateSpeedDisplay();
this.updateVolumeDisplays(); this.updateVolumeDisplays();
this.updateWebGLDisplays();
// Add event listeners for side effects when preferences change // Add event listeners for side effects when preferences change
document.addEventListener('preference-updated', (event) => { document.addEventListener('preference-updated', (event) => {
@@ -1115,6 +1223,10 @@ class OptionsUIModule extends BaseModule {
this.populateVoices(); this.populateVoices();
} }
} }
if (category === 'webgl') {
this.updateWebGLDisplays();
}
if (key === 'speed' && this.elements.ttsSpeed) { if (key === 'speed' && this.elements.ttsSpeed) {
this.updateSpeedDisplay(); this.updateSpeedDisplay();
} }
@@ -1155,6 +1267,21 @@ class OptionsUIModule extends BaseModule {
this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`; 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 // Create the singleton instance
+6
View File
@@ -67,6 +67,12 @@ class PersistenceManagerModule extends BaseModule {
localeUserOverride: false, localeUserOverride: false,
speed: 1.0, speed: 1.0,
autoplay: true, autoplay: true,
},
webgl: {
mode: null,
bookPageCount: 300,
bookProgress: 0,
pageReserve: 50
} }
}; };
+67 -2
View File
@@ -20,6 +20,8 @@ class PlaybackCoordinatorModule extends BaseModule {
'play', 'play',
'calculateWordTimings', 'calculateWordTimings',
'animateWords', 'animateWords',
'isWebGLPlaybackMode',
'scheduleWebGLReveal',
'waitForAudioStart', 'waitForAudioStart',
'completeSentenceVisual', 'completeSentenceVisual',
'accelerateActiveWordAnimations', 'accelerateActiveWordAnimations',
@@ -213,7 +215,7 @@ class PlaybackCoordinatorModule extends BaseModule {
* @returns {Promise<void>} - Resolves when animation completes * @returns {Promise<void>} - Resolves when animation completes
*/ */
async animateWords(sentence) { async animateWords(sentence) {
if (!sentence.element || !sentence.animation || !sentence.animation.wordTimings) { if (!sentence.animation || !sentence.animation.wordTimings) {
console.error('PlaybackCoordinator: Missing animation data'); console.error('PlaybackCoordinator: Missing animation data');
return Promise.resolve(); return Promise.resolve();
} }
@@ -224,6 +226,15 @@ class PlaybackCoordinatorModule extends BaseModule {
return Promise.resolve(); 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'); const wordElements = sentence.element.querySelectorAll('.word');
let wordTimings = sentence.animation.wordTimings; let wordTimings = sentence.animation.wordTimings;
let cueTimings = sentence.animation.cueTimings || []; let cueTimings = sentence.animation.cueTimings || [];
@@ -241,7 +252,6 @@ class PlaybackCoordinatorModule extends BaseModule {
}; };
}); });
} }
return new Promise((resolve) => { return new Promise((resolve) => {
const totalDuration = wordTimings.length > 0 const totalDuration = wordTimings.length > 0
? Math.max(...wordTimings.map(timing => timing.delay + timing.duration)) ? 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 * Calculate word-level timing to match total TTS duration
* This is a utility method that can be called by SentenceQueue during preparation * 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'); console.log('PlaybackCoordinator: Fast forwarding');
this.accelerateActiveWordAnimations(this.currentSentence); 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'); const animQueue = this.getModule('animation-queue');
if (animQueue) { if (animQueue) {
File diff suppressed because it is too large Load Diff
+179 -43
View File
@@ -7,21 +7,27 @@ import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000; const TTS_GENERATION_TIMEOUT_MS = 60000;
const ASSET_PRELOAD_TIMEOUT_MS = 60000; const ASSET_PRELOAD_TIMEOUT_MS = 60000;
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000; 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 { class SentenceQueueModule extends BaseModule {
constructor() { constructor() {
super('sentence-queue', 'Sentence Queue'); super('sentence-queue', 'Sentence Queue');
// Dependencies // 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 // Queue state
this.sentenceQueue = []; this.sentenceQueue = [];
this.isProcessing = false; this.isProcessing = false;
this.onSentenceReadyCallback = null; 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.prefetchingSpeech = new Map();
this.prefetchingWebGLBook = new Map();
this.preparedSentenceCache = new Map();
this.autoplay = true; this.autoplay = true;
this.inputMode = 'text'; this.inputMode = 'text';
this.lastContinueAt = 0; this.lastContinueAt = 0;
@@ -31,6 +37,7 @@ class SentenceQueueModule extends BaseModule {
this.generationRequests = new Map(); this.generationRequests = new Map();
this.assetPreloadRequests = new Map(); this.assetPreloadRequests = new Map();
this.queueGeneration = 0; this.queueGeneration = 0;
this.webglBookPrepareChain = Promise.resolve();
// Bind methods // Bind methods
this.bindMethods([ this.bindMethods([
@@ -43,6 +50,11 @@ class SentenceQueueModule extends BaseModule {
'getCacheKey', 'getCacheKey',
'getPreparedSentence', 'getPreparedSentence',
'prefetchAhead', 'prefetchAhead',
'prefetchWebGLBookPresentation',
'runWebGLBookPresentationPrepare',
'isWebGLBookPresentationPrepared',
'getWebGLBookPresentationKey',
'isWebGLBookPresentationEligible',
'prepareSpeechMetadata', 'prepareSpeechMetadata',
'preloadAssetsForItem', 'preloadAssetsForItem',
'normalizeTtsText', 'normalizeTtsText',
@@ -156,9 +168,12 @@ class SentenceQueueModule extends BaseModule {
text: String(queueItem.text || '').trim() 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) { if (!this.isProcessing) {
this.processNextSentence(); this.processNextSentence();
} else {
this.prefetchAhead(6, this.queueGeneration);
} }
} }
@@ -194,19 +209,27 @@ class SentenceQueueModule extends BaseModule {
const sentence = await this.getPreparedSentence(item); const sentence = await this.getPreparedSentence(item);
if (!this.isCurrentQueueItem(item, queueGeneration)) return; if (!this.isCurrentQueueItem(item, queueGeneration)) return;
if (!this.isWebGLBookPresentationPrepared(sentence)) {
// Prefetch far enough ahead that media pauses do not block TTS await this.prefetchWebGLBookPresentation(sentence, {
// generation for the next spoken paragraph. queueGeneration,
this.prefetchAhead(4, queueGeneration); queueIndex: 0,
immediate: true
});
}
if (!this.isCurrentQueueItem(item, queueGeneration)) return; if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Notify display handler with complete sentence // Notify display handler with complete sentence
if (this.onSentenceReadyCallback) { if (this.onSentenceReadyCallback) {
await new Promise(resolve => { const playbackFinished = new Promise(resolve => {
sentence.onComplete = resolve; sentence.onComplete = resolve;
sentence.playbackStartedAt = performance.now(); sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve); 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; if (!this.isCurrentQueueItem(item, queueGeneration)) return;
} }
@@ -499,14 +522,15 @@ class SentenceQueueModule extends BaseModule {
* Prepare queue metadata. This module intentionally does not create layout: * Prepare queue metadata. This module intentionally does not create layout:
* live rendering and history rendering must go through the same renderer. * 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 text = typeof item === 'string' ? item : item.text;
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const metadata = typeof item === 'object' && item !== null ? item : {}; const metadata = typeof item === 'object' && item !== null ? item : {};
const blocking = options.blocking !== false;
try { try {
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) { 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 { return {
id, id,
@@ -529,7 +553,7 @@ class SentenceQueueModule extends BaseModule {
await this.preloadAssetsForItem({ await this.preloadAssetsForItem({
type: 'paragraph', type: 'paragraph',
cueMarkers: metadata.cueMarkers || [] cueMarkers: metadata.cueMarkers || []
}, { blocking: true, sentenceId: id }); }, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
} }
const ttsData = await this.prepareSpeechMetadata(text, { const ttsData = await this.prepareSpeechMetadata(text, {
@@ -537,7 +561,7 @@ class SentenceQueueModule extends BaseModule {
blockId: metadata.blockId ?? null, blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null, turnId: metadata.turnId ?? null,
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [], ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
blocking: true blocking
}); });
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`); console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
@@ -834,7 +858,7 @@ class SentenceQueueModule extends BaseModule {
resolve(); resolve();
}; };
const onCommand = (event) => { const onCommand = (event) => {
if (event.detail?.type === 'continue') { if (event.detail?.type === 'continue' && !this.isChoiceAwaitingPlayer()) {
finish(); finish();
} }
}; };
@@ -846,20 +870,122 @@ class SentenceQueueModule extends BaseModule {
return `${item?.id || ''}:${item?.text || ''}`; 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) { 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) { if (pending) {
pending.catch(() => null); const prefetched = await pending.catch(() => null);
if (prefetched) {
this.preparedSentenceCache.delete(cacheKey);
return prefetched;
}
} }
return this.prepareSentence(item); 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) { isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item; 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) { if (this.sentenceQueue.length <= 1) {
document.dispatchEvent(new CustomEvent('story:process-state', { document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id } 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 started = 0;
let spokenPrepared = 0; let webglBookLookahead = 0;
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1); const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
const allowWebGLBookPrefetch = document.documentElement.dataset.webglBookPlaybackActive === 'true';
for (let index = 1; index < limit; index += 1) { for (let index = 1; index < limit; index += 1) {
const nextItem = this.sentenceQueue[index]; const nextItem = this.sentenceQueue[index];
const nextCacheKey = this.getCacheKey(nextItem); 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.prefetchingSpeech.has(nextCacheKey)) {
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
continue; continue;
} }
@@ -888,35 +1033,30 @@ class SentenceQueueModule extends BaseModule {
const promise = (async () => { const promise = (async () => {
if (queueGeneration !== this.queueGeneration) return null; if (queueGeneration !== this.queueGeneration) return null;
await this.preloadAssetsForItem(nextItem, { const prepared = await this.prepareSentence(nextItem, {
sentenceId: nextItem.id,
blocking: false, blocking: false,
prefetch: true prefetch: true,
queueIndex: index
}); });
if (queueGeneration !== this.queueGeneration) return null; if (queueGeneration !== this.queueGeneration) return null;
if (shouldPrepareWebGLBook) {
if (!this.isSpeechItem(nextItem)) { await this.prefetchWebGLBookPresentation(prepared, {
return null; queueGeneration,
queueIndex: index
});
} }
if (queueGeneration !== this.queueGeneration) return null;
return this.prepareSpeechMetadata(nextItem.text || '', { this.preparedSentenceCache.set(nextCacheKey, prepared);
sentenceId: nextItem.id, return prepared;
blockId: nextItem.blockId ?? null,
turnId: nextItem.turnId ?? null,
ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [],
queueIndex: index,
prefetch: true,
blocking: false
});
})() })()
.then(() => { .then((prepared) => {
if (queueGeneration !== this.queueGeneration) return false; if (queueGeneration !== this.queueGeneration) return false;
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index }); console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
document.dispatchEvent(new CustomEvent('story:process-state', { document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index } 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 }); console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
return true; return prepared || true;
}) })
.catch(err => { .catch(err => {
console.warn('SentenceQueue: Prefetch failed:', err); console.warn('SentenceQueue: Prefetch failed:', err);
@@ -929,13 +1069,6 @@ class SentenceQueueModule extends BaseModule {
this.prefetchingSpeech.set(nextCacheKey, promise); this.prefetchingSpeech.set(nextCacheKey, promise);
started += 1; started += 1;
if (this.isSpeechItem(nextItem)) {
spokenPrepared += 1;
}
if (spokenPrepared >= 1 && started >= 2) {
break;
}
} }
if (started === 0) { if (started === 0) {
@@ -1341,6 +1474,9 @@ class SentenceQueueModule extends BaseModule {
this.cancelGenerationRequests('sentence-queue-cleared'); this.cancelGenerationRequests('sentence-queue-cleared');
this.cancelAssetPreloads('sentence-queue-cleared'); this.cancelAssetPreloads('sentence-queue-cleared');
this.prefetchingSpeech.clear(); this.prefetchingSpeech.clear();
this.prefetchingWebGLBook.clear();
this.preparedSentenceCache.clear();
this.webglBookPrepareChain = Promise.resolve();
this.pauseBeforeNextReason = null; this.pauseBeforeNextReason = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', { document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' } detail: { reason: 'sentence-queue-cleared' }
+7
View File
@@ -157,6 +157,13 @@ class StoryHistoryModule extends BaseModule {
...record, ...record,
lineStart, lineStart,
lineCount, 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() metricsUpdatedAt: Date.now()
}; };
+31
View File
@@ -24,6 +24,7 @@ class TextProcessorModule extends BaseModule {
'hyphenate', 'hyphenate',
'setLocale', 'setLocale',
'loadHyphenopolyLoader', 'loadHyphenopolyLoader',
'ensureHyphenopolySeedElements',
'normalizeHyphenationLocale', 'normalizeHyphenationLocale',
'applyLocaleTypography', 'applyLocaleTypography',
'getTypographyLocale', 'getTypographyLocale',
@@ -162,6 +163,7 @@ class TextProcessorModule extends BaseModule {
this.hyphenatorReady = false; this.hyphenatorReady = false;
await this.loadHyphenopolyLoader(); await this.loadHyphenopolyLoader();
this.ensureHyphenopolySeedElements(locale);
window.Hyphenopoly.config({ window.Hyphenopoly.config({
require: { 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() { loadHyphenopolyLoader() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (window.Hyphenopoly && typeof window.Hyphenopoly.config === 'function') { if (window.Hyphenopoly && typeof window.Hyphenopoly.config === 'function') {
+17 -1
View File
@@ -29,6 +29,7 @@ class UIControllerModule extends BaseModule {
this.ttsHandler = null; this.ttsHandler = null;
this.socketClient = null; this.socketClient = null;
this.animationQueue = null; this.animationQueue = null;
this.currentInputMode = document.documentElement.dataset.inputMode || 'none';
// Add TTS toggle state // Add TTS toggle state
this.ttsEnabled = false; this.ttsEnabled = false;
@@ -56,6 +57,7 @@ class UIControllerModule extends BaseModule {
'clearDisplay', 'clearDisplay',
'sendCommand', 'sendCommand',
'isInteractiveClickTarget', 'isInteractiveClickTarget',
'isChoiceAwaitingPlayer',
'updateButtonStates' 'updateButtonStates'
]); ]);
} }
@@ -262,6 +264,9 @@ class UIControllerModule extends BaseModule {
if (!event.detail || event.detail.moduleId === this.id) return; if (!event.detail || event.detail.moduleId === this.id) return;
this.handleCommand(event.detail); 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) => { this.addEventListener(document, 'click', (event) => {
if (this.isInteractiveClickTarget(event.target)) { if (this.isInteractiveClickTarget(event.target)) {
@@ -270,7 +275,7 @@ class UIControllerModule extends BaseModule {
const playbackCoordinator = this.getModule('playback-coordinator'); const playbackCoordinator = this.getModule('playback-coordinator');
const hasSkippablePause = document.documentElement.dataset.skippablePause === 'true'; 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' }); this.handleCommand({ type: 'continue', source: 'book-click' });
} }
@@ -668,6 +673,14 @@ class UIControllerModule extends BaseModule {
].join(','))); ].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) { handleCommand(command) {
// Route commands to appropriate handlers // Route commands to appropriate handlers
switch (command.type) { switch (command.type) {
@@ -679,6 +692,9 @@ class UIControllerModule extends BaseModule {
break; break;
case 'continue': case 'continue':
{ {
if (this.isChoiceAwaitingPlayer()) {
return;
}
document.dispatchEvent(new CustomEvent('ui:command', { document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' } detail: { moduleId: this.id, type: 'continue', source: command.source || 'ui-controller-forward' }
})); }));
+64 -8
View File
@@ -11,7 +11,7 @@ class UIDisplayHandlerModule extends BaseModule {
super('ui-display-handler', 'UI Display Handler'); super('ui-display-handler', 'UI Display Handler');
// Module dependencies // Module dependencies
this.dependencies = ['layout-renderer', 'playback-coordinator', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser']; this.dependencies = ['layout-renderer', 'webgl-book-scene', 'playback-coordinator', 'book-playback-timeline', 'game-config', 'localization', 'story-history', 'sentence-queue', 'persistence-manager', 'markup-parser', 'book-pagination', 'book-texture-renderer'];
// DOM elements // DOM elements
this.container = null; this.container = null;
@@ -68,6 +68,9 @@ class UIDisplayHandlerModule extends BaseModule {
'applyGameConfig', 'applyGameConfig',
'applyTranslations', 'applyTranslations',
'renderSentence', 'renderSentence',
'isWebGLMode',
'playWebGLBookSentence',
'prepareWebGLBookReveal',
'renderStoryBlock', 'renderStoryBlock',
'prepareRenderableBlock', 'prepareRenderableBlock',
'prepareTextRenderable', 'prepareTextRenderable',
@@ -170,6 +173,7 @@ class UIDisplayHandlerModule extends BaseModule {
// Get references to required modules using parent's getModule method // Get references to required modules using parent's getModule method
this.layoutRenderer = this.getModule('layout-renderer'); this.layoutRenderer = this.getModule('layout-renderer');
this.webglBookScene = this.getModule('webgl-book-scene');
this.playbackCoordinator = this.getModule('playback-coordinator'); this.playbackCoordinator = this.getModule('playback-coordinator');
this.gameConfig = this.getModule('game-config'); this.gameConfig = this.getModule('game-config');
this.localization = this.getModule('localization'); this.localization = this.getModule('localization');
@@ -355,6 +359,8 @@ class UIDisplayHandlerModule extends BaseModule {
* Initialize the UI containers * Initialize the UI containers
*/ */
initializeContainers() { initializeContainers() {
this.webglBookScene?.ensureShell?.();
// Check if the book container already exists // Check if the book container already exists
let bookContainer = document.getElementById('book'); let bookContainer = document.getElementById('book');
if (!bookContainer) { if (!bookContainer) {
@@ -526,7 +532,10 @@ class UIDisplayHandlerModule extends BaseModule {
this.createNotificationDialog(); this.createNotificationDialog();
console.log('UIDisplayHandler: All containers initialized'); console.log('UIDisplayHandler: All containers initialized');
this.webglBookScene?.adoptPageContent?.();
this.webglBookScene?.refreshModalOverview?.();
this.applyGameConfig(this.gameConfig?.getConfig?.()); this.applyGameConfig(this.gameConfig?.getConfig?.());
this.webglBookScene?.adoptPageContent?.();
this.applyTranslations(); this.applyTranslations();
this.measureStoryLineHeight(); this.measureStoryLineHeight();
this.setStoryOffset(0); this.setStoryOffset(0);
@@ -972,8 +981,18 @@ class UIDisplayHandlerModule extends BaseModule {
const generation = this.displayGeneration; const generation = this.displayGeneration;
const sentenceGameId = sentence.gameId || null; const sentenceGameId = sentence.gameId || null;
const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId); const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId);
const useWebGLBookReveal = this.isWebGLMode() && (sentence.kind === 'paragraph' || sentence.kind === 'heading');
try { 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(); await this.ensureLiveTailWindow();
if (!isCurrent()) return null; if (!isCurrent()) return null;
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false }); await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
@@ -985,7 +1004,8 @@ class UIDisplayHandlerModule extends BaseModule {
playback: true, playback: true,
placement: 'append', placement: 'append',
token: this.renderWindowToken, token: this.renderWindowToken,
generation generation,
deferRenderedMark: useWebGLBookReveal
}); });
if (!element) return null; if (!element) return null;
if (!isCurrent()) { if (!isCurrent()) {
@@ -1002,7 +1022,14 @@ class UIDisplayHandlerModule extends BaseModule {
if (sentence.kind === 'image') { if (sentence.kind === 'image') {
this.revealImageBlock(element); this.revealImageBlock(element);
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') { } 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') { } else if (sentence.kind === 'music') {
console.log('UIDisplayHandler: Music block started', sentence.metadata || {}); 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() { async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return; if (!this.paragraphContainer || this.renderedItems.length === 0) return;
console.log('UIDisplayHandler: Re-typesetting story after page resize'); console.log('UIDisplayHandler: Re-typesetting story after page resize');
@@ -1091,7 +1139,8 @@ class UIDisplayHandlerModule extends BaseModule {
renderedItemsTarget = this.renderedItems, renderedItemsTarget = this.renderedItems,
token = null, token = null,
recordMetrics = true, recordMetrics = true,
generation = this.displayGeneration generation = this.displayGeneration,
deferRenderedMark = false
} = options; } = options;
if (!item || !this.paragraphContainer) return null; if (!item || !this.paragraphContainer) return null;
const renderable = await this.prepareRenderableBlock(item); const renderable = await this.prepareRenderableBlock(item);
@@ -1138,7 +1187,7 @@ class UIDisplayHandlerModule extends BaseModule {
} }
if (item.blockId != null) { if (item.blockId != null) {
element.dataset.storyBlockId = String(item.blockId); element.dataset.storyBlockId = String(item.blockId);
this.markBlockRendered(item.blockId); if (!deferRenderedMark) this.markBlockRendered(item.blockId);
} }
element.dataset.lineStart = String(renderable.lineStart); element.dataset.lineStart = String(renderable.lineStart);
element.dataset.lineCount = String(renderable.lineCount); element.dataset.lineCount = String(renderable.lineCount);
@@ -1755,7 +1804,7 @@ class UIDisplayHandlerModule extends BaseModule {
} }
getLatestHistoryBlockId() { getLatestHistoryBlockId() {
return Math.max(0, Number(this.storyHistory?.latestRenderedBlockId || 0)); return Math.max(0, Number((this.storyHistory?.nextBlockId || 1) - 1));
} }
updateStoryScrollbar(detail = {}) { updateStoryScrollbar(detail = {}) {
@@ -2394,8 +2443,15 @@ class UIDisplayHandlerModule extends BaseModule {
const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen' const normalizedSize = String(metadata.size || 'landscape').toLowerCase() === 'widescreen'
? 'landscape' ? 'landscape'
: String(metadata.size || 'landscape').toLowerCase(); : 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 isPortrait = normalizedSize === 'portrait';
const isFullPage = normalizedSize === 'full';
const imageGap = lineHeight; const imageGap = lineHeight;
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth; const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
const maxImageWidth = isPortrait const maxImageWidth = isPortrait
@@ -2404,7 +2460,7 @@ class UIDisplayHandlerModule extends BaseModule {
const naturalHeight = maxImageWidth / aspect; const naturalHeight = maxImageWidth / aspect;
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight)); const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
const verticalMargin = lineHeight / 2; 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 height = Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2));
const width = Math.min(maxImageWidth, height * aspect); const width = Math.min(maxImageWidth, height * aspect);
File diff suppressed because it is too large Load Diff
+18
View File
@@ -308,6 +308,18 @@ class UIInputHandlerModule extends BaseModule {
normalizeProcessState(state) { normalizeProcessState(state) {
const playbackCoordinator = this.getModule('playback-coordinator'); const playbackCoordinator = this.getModule('playback-coordinator');
const isPlaying = Boolean(playbackCoordinator?.isPlaying); 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') { if (isPlaying && state === 'ready') {
return 'playing-ready'; return 'playing-ready';
@@ -345,6 +357,12 @@ class UIInputHandlerModule extends BaseModule {
this.setInputModeDataset(); this.setInputModeDataset();
const state = document.documentElement.dataset.processState || 'loading'; const state = document.documentElement.dataset.processState || 'loading';
this.setInputAvailability(this.inputMode === 'text' && state === 'ready'); 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() { setInputModeDataset() {
File diff suppressed because it is too large Load Diff
+605
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+696
View File
@@ -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;
+21
View File
@@ -29,6 +29,13 @@
"options.voice": "Stimme", "options.voice": "Stimme",
"options.speed": "Tempo", "options.speed": "Tempo",
"options.audio": "Audio", "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.volume": "Lautstärke",
"options.masterVolume": "Gesamtlautstärke", "options.masterVolume": "Gesamtlautstärke",
"options.speechVolume": "Sprachlautstärke", "options.speechVolume": "Sprachlautstärke",
@@ -53,6 +60,20 @@
"options.apiUrl": "API-URL", "options.apiUrl": "API-URL",
"options.model": "Modell", "options.model": "Modell",
"options.requestTimeoutMs": "Anfrage-Timeout (ms)", "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.button": "Credits",
"credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen", "credits.buttonTitle": "Mitwirkende und Lizenzen anzeigen",
"credits.title": "Mitwirkende und Lizenzen", "credits.title": "Mitwirkende und Lizenzen",
+21
View File
@@ -29,6 +29,13 @@
"options.voice": "Voice", "options.voice": "Voice",
"options.speed": "Speed", "options.speed": "Speed",
"options.audio": "Audio", "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.volume": "Volume",
"options.masterVolume": "Master Volume", "options.masterVolume": "Master Volume",
"options.speechVolume": "Speech Volume", "options.speechVolume": "Speech Volume",
@@ -53,6 +60,20 @@
"options.apiUrl": "API URL", "options.apiUrl": "API URL",
"options.model": "Model", "options.model": "Model",
"options.requestTimeoutMs": "Request timeout (ms)", "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.button": "credits",
"credits.buttonTitle": "Show credits and third-party licenses", "credits.buttonTitle": "Show credits and third-party licenses",
"credits.title": "Credits and Licenses", "credits.title": "Credits and Licenses",
+146
View File
@@ -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>
+79
View File
@@ -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>
+280
View File
@@ -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}).`);
+282
View File
@@ -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);
});
+105
View File
@@ -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()
+2
View File
@@ -10,6 +10,8 @@ export interface GameMetadata {
version?: string; version?: string;
copyright?: string; copyright?: string;
language?: string; language?: string;
bookPageCount?: number;
pageReserve?: number;
} }
export interface GamePaths { export interface GamePaths {
Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 KiB