diff --git a/src/App.ts b/src/App.ts index aac3ec9..e2a7f3f 100644 --- a/src/App.ts +++ b/src/App.ts @@ -2623,7 +2623,8 @@ function handleQuakeDebugRecordingButtonClick(event: Event): void { function syncQuakeInteractionPresentation(): void { const menuSurfaceOpen = menu.isMainMenuOpen() || menu.isMenuPanelOpen(); const pointerUnlocked = document.pointerLockElement !== host; - const gameplayPointerUnlocked = quakeGameplayStarted && pointerUnlocked; + const mobileControlsAvailable = quakePointerGameplay.isMobileAvailable(); + const gameplayPointerUnlocked = quakeGameplayStarted && pointerUnlocked && !mobileControlsAvailable; const debugPointerUnlocked = quakeDebugPanelFlow.isModeEnabled() && pointerUnlocked; const clickToPlayVisible = gameplayPointerUnlocked && !menuSurfaceOpen; setQuakeClickToPlayPauseState(clickToPlayVisible); @@ -4394,7 +4395,7 @@ async function completeQuakeSceneReadiness( ): Promise { const completeWorldTexturesTask = progress?.startTask("World textures"); try { - await world.waitForVisibleAtlasPages(); + await world.waitForVisibleTextures(); } finally { completeWorldTexturesTask?.(); } diff --git a/src/quake.css b/src/quake.css index 51f4df2..8c78fed 100644 --- a/src/quake.css +++ b/src/quake.css @@ -2398,20 +2398,21 @@ body.quake-menu-unlocked #quake-weapon { user-select: none; touch-action: none; --quake-mobile-control-center-bottom: max(142px, calc(env(safe-area-inset-bottom) + 142px)); + --quake-mobile-move-zone-left: max(18px, env(safe-area-inset-left)); } #quake-mobile-look-zone { position: absolute; top: 0; right: 0; bottom: 0; - left: 34vw; + left: max(34vw, calc(var(--quake-mobile-move-zone-left) + 156px)); z-index: 0; pointer-events: auto; touch-action: none; } #quake-mobile-move-zone { position: absolute; - left: max(18px, env(safe-area-inset-left)); + left: var(--quake-mobile-move-zone-left); bottom: calc(var(--quake-mobile-control-center-bottom) - 72px); width: 144px; height: 144px; diff --git a/src/runtime/renderBundleMesh.ts b/src/runtime/renderBundleMesh.ts index 8295c7f..64e6e89 100644 --- a/src/runtime/renderBundleMesh.ts +++ b/src/runtime/renderBundleMesh.ts @@ -771,6 +771,29 @@ export async function preloadQuakeRenderBundleAtlasPages( await preloadQuakeRenderBundleAssetUrls(urls); } +export async function preloadQuakeRenderBundleElementAssets( + meshElement: HTMLElement, + elements: Iterable, +): Promise { + const urls = quakeRenderBundleElementAssetUrls(meshElement, elements); + await preloadQuakeRenderBundleAssetUrls(urls); +} + +export function quakeRenderBundleElementAssetUrls( + meshElement: HTMLElement, + elements: Iterable, +): string[] { + const urls = new Set(); + for (const element of elements) { + collectQuakeRenderBundleInlineAssetUrls( + urls, + element.getAttribute("style") ?? "", + (varName) => meshElement.style.getPropertyValue(varName), + ); + } + return [...urls]; +} + export interface QuakeRenderBundleDebugOutlineOptions { hideTextures?: boolean; } @@ -1263,6 +1286,31 @@ export function quakeRenderBundlePreloadAssetUrls(renderBundle: QuakePreparedRen return [...urls]; } +function collectQuakeRenderBundleInlineAssetUrls( + urls: Set, + styleText: string, + resolveVar: (name: string) => string | undefined, +): void { + if (!styleText) return; + for (const match of styleText.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)]*?))\s*\)/g)) { + const url = normalizeQuakeRenderBundleCssUrl(match[1] ?? match[2] ?? match[3] ?? ""); + if (url) urls.add(url); + } + for (const match of styleText.matchAll(/var\(\s*(--bg\d+)\s*\)/g)) { + const value = resolveVar(match[1] ?? ""); + if (value) collectQuakeRenderBundleInlineAssetUrls(urls, value, resolveVar); + } +} + +function normalizeQuakeRenderBundleCssUrl(value: string): string { + let url = value.trim(); + if (!url) return ""; + if ((url.startsWith(""") && url.endsWith(""")) || (url.startsWith(""") && url.endsWith("""))) { + url = url.slice(url.indexOf(";") + 1, url.lastIndexOf("&")); + } + return url.trim(); +} + function preloadQuakeRenderBundleStyle(renderBundle: QuakePreparedRenderBundle): Promise { const key = quakeRenderBundleStyleKey(renderBundle); if (!key) return Promise.resolve(); diff --git a/src/runtime/world.ts b/src/runtime/world.ts index f0a7a00..a3b0f79 100644 --- a/src/runtime/world.ts +++ b/src/runtime/world.ts @@ -25,6 +25,7 @@ import { exposeQuakeRenderBundleAtlasPages, mountQuakeRenderBundleMesh, preloadQuakeRenderBundleAtlasPages, + preloadQuakeRenderBundleElementAssets, registerQuakeRenderBundleDebugLeafSourceFace, registerQuakeRenderBundleDebugOutlineLeaves, stripPolyMeshMetadata, @@ -155,6 +156,7 @@ export interface QuakeWorldController { syncVisibility: (force?: boolean) => void; visibleLeavesAt: (origin: [number, number, number]) => Set | null; waitForVisibleAtlasPages: () => Promise; + waitForVisibleTextures: () => Promise; } export interface QuakeWorldDebugBucket { @@ -214,6 +216,8 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) let visibleAtlasPageKey = ""; let visibleAtlasPageSet = new Set(); let visibleAtlasPageReadyPromise: Promise = Promise.resolve(); + let visibleAtlasPrewarmReadyPromise: Promise = Promise.resolve(); + let visibleWorldTextureReadyPromise: Promise = Promise.resolve(); let debugShellVisible = false; const clear = (): void => { @@ -241,6 +245,8 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) visibleAtlasPageKey = ""; visibleAtlasPageSet = new Set(); visibleAtlasPageReadyPromise = Promise.resolve(); + visibleAtlasPrewarmReadyPromise = Promise.resolve(); + visibleWorldTextureReadyPromise = Promise.resolve(); }; const clearPresentationResyncTimers = (): void => { @@ -1129,6 +1135,12 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) }; const waitForVisibleAtlasPages = (): Promise => visibleAtlasPageReadyPromise; + const waitForVisibleTextures = (): Promise => + Promise.all([ + visibleAtlasPageReadyPromise, + visibleAtlasPrewarmReadyPromise, + visibleWorldTextureReadyPromise, + ]).then(() => undefined); const syncWorldAtlasResidencyPages = (leafIndex: number | null): void => { const atlasResidency = currentRenderBundle?.atlasResidency; @@ -1143,7 +1155,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) const exposedPages = currentPages.length ? currentPages : allPages; const warmPages = prewarmPages.length ? prewarmPages : exposedPages; setVisibleAtlasResidencyPages(exposedPages); - void preloadQuakeRenderBundleAtlasPages(currentRenderBundle, warmPages); + visibleAtlasPrewarmReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, warmPages); }; const setVisibleAtlasResidencyPages = (pageIndexes: readonly number[]): void => { @@ -1159,14 +1171,38 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) }; const syncMountedAtlasResidencyPages = (): void => { - if (!currentHandle || !currentRenderBundle || currentRenderBundle.atlasResidency?.mode !== "pvs-pages") return; - const nextPages = mountedAtlasResidencyPageIndexes(visibleAtlasPageSet); - const pageKey = nextPages.join(","); - if (pageKey === visibleAtlasPageKey) return; - exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, nextPages); - visibleAtlasPageSet = new Set(nextPages); - visibleAtlasPageKey = pageKey; - visibleAtlasPageReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages); + if (!currentHandle || !currentRenderBundle) return; + if (currentRenderBundle.atlasResidency?.mode === "pvs-pages") { + const nextPages = mountedAtlasResidencyPageIndexes(visibleAtlasPageSet); + const pageKey = nextPages.join(","); + if (pageKey !== visibleAtlasPageKey) { + exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, nextPages); + visibleAtlasPageSet = new Set(nextPages); + visibleAtlasPageKey = pageKey; + visibleAtlasPageReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages); + } + } + syncMountedWorldTextureReadiness(); + }; + + const syncMountedWorldTextureReadiness = (): void => { + if (!currentHandle) { + visibleWorldTextureReadyPromise = Promise.resolve(); + return; + } + visibleWorldTextureReadyPromise = preloadQuakeRenderBundleElementAssets( + currentHandle.element, + mountedWorldTextureElements(), + ); + }; + + const mountedWorldTextureElements = (): HTMLElement[] => { + const elements: HTMLElement[] = []; + for (const leaf of quakeLeaves) { + if (leaf.meshKind !== "world" || !leaf.mounted || !leaf.element.isConnected) continue; + elements.push(leaf.element); + } + return elements; }; const normalizedAtlasResidencyPageIndexes = (pageIndexes: Iterable): number[] => @@ -1353,6 +1389,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) syncVisibility, visibleLeavesAt: (origin: [number, number, number]) => currentVisibility?.visibleLeavesAt(origin) ?? null, waitForVisibleAtlasPages, + waitForVisibleTextures, }; } diff --git a/test/runtime/renderBundlePreloadUrls.test.mjs b/test/runtime/renderBundlePreloadUrls.test.mjs index d9d2c3c..b415a74 100644 --- a/test/runtime/renderBundlePreloadUrls.test.mjs +++ b/test/runtime/renderBundlePreloadUrls.test.mjs @@ -1,9 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { Window } from "happy-dom"; + import { importTsModule } from "../importTsModule.mjs"; const { + quakeRenderBundleElementAssetUrls, quakeRenderBundlePreloadAssetUrls, } = await importTsModule("src/runtime/renderBundleMesh.ts"); @@ -44,3 +47,31 @@ test("render bundles without complete asset URLs fail before runtime preload", ( /assetUrls must be complete/, ); }); + +test("mounted render bundle leaves expose direct URLs and atlas root-var URLs for preload", () => { + const window = new Window(); + const mesh = window.document.createElement("div"); + mesh.className = "polycss-mesh"; + mesh.style.setProperty("--bg0", 'url("/q/b/e1m1/a0.png")'); + + const directLeaf = window.document.createElement("s"); + directLeaf.setAttribute( + "style", + 'background:url("/q/b/e1m1/direct.png") 0px 0px / 64px 64px no-repeat', + ); + const atlasLeaf = window.document.createElement("s"); + atlasLeaf.setAttribute( + "style", + "background:var(--bg0) 0px 0px / 64px 64px no-repeat", + ); + const duplicateAtlasLeaf = window.document.createElement("s"); + duplicateAtlasLeaf.setAttribute( + "style", + "background-image:var(--bg0)", + ); + + assert.deepEqual( + quakeRenderBundleElementAssetUrls(mesh, [directLeaf, atlasLeaf, duplicateAtlasLeaf]), + ["/q/b/e1m1/direct.png", "/q/b/e1m1/a0.png"], + ); +});