diff --git a/src/runtime/app/session.ts b/src/runtime/app/session.ts index 90da9e8..7f29b04 100644 --- a/src/runtime/app/session.ts +++ b/src/runtime/app/session.ts @@ -5,7 +5,7 @@ import { quakeLoadingProgressGroup, type QuakeLoadingProgressTracker, } from "../loadingConsole"; -import { preloadQuakeRenderBundleAssets } from "../renderBundleMesh"; +import { preloadQuakeRenderBundleAssets, preloadQuakeRenderBundleFloorAssets } from "../renderBundleMesh"; import type { QuakeUrlUpdateMode, QuakeUrlView } from "../routeState"; export const QUAKE_ASSET_ROOT = "/q"; @@ -200,7 +200,10 @@ export async function fetchQuakeScene( } const renderBundlePreloads = [ ...(prepared.renderBundle - ? [preloadQuakeRenderBundleAssets(prepared.renderBundle, worldProgress, { preloadImages: false })] + ? [ + preloadQuakeRenderBundleAssets(prepared.renderBundle, worldProgress, { preloadImages: false }), + preloadQuakeRenderBundleFloorAssets(prepared.renderBundle, mapName ?? "", worldProgress), + ] : []), ...(prepared.lightstyleRenderBundle ? [preloadQuakeRenderBundleAssets(prepared.lightstyleRenderBundle, worldProgress, { preloadImages: false })] diff --git a/src/runtime/mobileControls.ts b/src/runtime/mobileControls.ts index db2e9db..23ff087 100644 --- a/src/runtime/mobileControls.ts +++ b/src/runtime/mobileControls.ts @@ -3,6 +3,12 @@ import { markQuakeTrace } from "./debug/traceMarks"; export const QUAKE_MOBILE_CONTROLS_QUERY = "(any-pointer: coarse), (max-width: 960px)"; +const QUAKE_MOBILE_MOVE_ZONE_SIZE = 144; +const QUAKE_MOBILE_STICK_SIZE = 108; +const QUAKE_MOBILE_STICK_FRONT_SIZE = 54; +const QUAKE_MOBILE_STICK_FRONT_TRAVEL = QUAKE_MOBILE_STICK_SIZE / 4; +const QUAKE_MOBILE_STICK_CENTER = QUAKE_MOBILE_MOVE_ZONE_SIZE / 2; + interface QuakeMobileControlsOptions { root: HTMLElement; moveDeadzone: number; @@ -48,6 +54,10 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): let moveX = 0; let moveY = 0; let movePointerId: number | null = null; + let moveAnchorX = 0; + let moveAnchorY = 0; + let moveStickCenterX = QUAKE_MOBILE_STICK_CENTER; + let moveStickCenterY = QUAKE_MOBILE_STICK_CENTER; let moveStartedAt = 0; let moveSampleCount = 0; let lookPointerId: number | null = null; @@ -297,6 +307,7 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): x: event.clientX, y: event.clientY, }); + setMoveAnchor(event); try { moveZone?.setPointerCapture(event.pointerId); } catch { @@ -336,9 +347,16 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): return; } moveSampleCount++; - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - setMoveInput((event.clientX - centerX) / radius, (centerY - event.clientY) / radius, phase); + setMoveInput((event.clientX - moveAnchorX) / radius, (moveAnchorY - event.clientY) / radius, phase); + } + + function setMoveAnchor(event: PointerEvent): void { + const rect = moveZone?.getBoundingClientRect(); + moveAnchorX = event.clientX; + moveAnchorY = event.clientY; + moveStickCenterX = rect ? event.clientX - rect.left : QUAKE_MOBILE_STICK_CENTER; + moveStickCenterY = rect ? event.clientY - rect.top : QUAKE_MOBILE_STICK_CENTER; + syncMoveStickVisual(0, 0, true); } function setMoveInput(x: number, y: number, source: "start" | "move"): void { @@ -395,11 +413,15 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): } } movePointerId = null; + moveAnchorX = 0; + moveAnchorY = 0; moveX = 0; moveY = 0; moveTime = 0; moveStartedAt = 0; moveSampleCount = 0; + moveStickCenterX = QUAKE_MOBILE_STICK_CENTER; + moveStickCenterY = QUAKE_MOBILE_STICK_CENTER; syncMoveStickVisual(0, 0, false); options.onAnalogMove(0, 0); if (!moveFrame) return; @@ -444,17 +466,14 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): const back = moveStickBack; const front = moveStickFront; if (!stick || !back || !front) return; - const stickSize = 108; - const frontSize = 54; - const frontTravel = stickSize / 4; stick.style.position = "absolute"; stick.style.display = "block"; - stick.style.left = "50%"; - stick.style.top = "50%"; - stick.style.width = `${stickSize}px`; - stick.style.height = `${stickSize}px`; - stick.style.marginLeft = `${-stickSize / 2}px`; - stick.style.marginTop = `${-stickSize / 2}px`; + stick.style.left = `${moveStickCenterX}px`; + stick.style.top = `${moveStickCenterY}px`; + stick.style.width = `${QUAKE_MOBILE_STICK_SIZE}px`; + stick.style.height = `${QUAKE_MOBILE_STICK_SIZE}px`; + stick.style.marginLeft = `${-QUAKE_MOBILE_STICK_SIZE / 2}px`; + stick.style.marginTop = `${-QUAKE_MOBILE_STICK_SIZE / 2}px`; stick.style.opacity = active ? "1" : "0.58"; stick.style.touchAction = "none"; stick.style.userSelect = "none"; @@ -465,8 +484,8 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): back.style.display = "block"; back.style.left = "0px"; back.style.top = "0px"; - back.style.width = `${stickSize}px`; - back.style.height = `${stickSize}px`; + back.style.width = `${QUAKE_MOBILE_STICK_SIZE}px`; + back.style.height = `${QUAKE_MOBILE_STICK_SIZE}px`; back.style.marginLeft = "0px"; back.style.marginTop = "0px"; back.style.borderRadius = "50%"; @@ -479,17 +498,17 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): front.style.display = "block"; front.style.left = "50%"; front.style.top = "50%"; - front.style.width = `${frontSize}px`; - front.style.height = `${frontSize}px`; - front.style.marginLeft = `${-frontSize / 2}px`; - front.style.marginTop = `${-frontSize / 2}px`; + front.style.width = `${QUAKE_MOBILE_STICK_FRONT_SIZE}px`; + front.style.height = `${QUAKE_MOBILE_STICK_FRONT_SIZE}px`; + front.style.marginLeft = `${-QUAKE_MOBILE_STICK_FRONT_SIZE / 2}px`; + front.style.marginTop = `${-QUAKE_MOBILE_STICK_FRONT_SIZE / 2}px`; front.style.borderRadius = "50%"; front.style.background = "rgba(245, 232, 200, 0.18)"; front.style.opacity = "0.5"; front.style.boxSizing = "border-box"; front.style.border = "2px solid rgba(245, 232, 200, 0.48)"; front.style.pointerEvents = "none"; - front.style.transform = `translate(${x * frontTravel}px, ${-y * frontTravel}px)`; + front.style.transform = `translate(${x * QUAKE_MOBILE_STICK_FRONT_TRAVEL}px, ${-y * QUAKE_MOBILE_STICK_FRONT_TRAVEL}px)`; } function handleFirePointerDown(event: PointerEvent): void { diff --git a/src/runtime/renderBundleMesh.ts b/src/runtime/renderBundleMesh.ts index 64e6e89..8e78eb3 100644 --- a/src/runtime/renderBundleMesh.ts +++ b/src/runtime/renderBundleMesh.ts @@ -94,6 +94,7 @@ const QUAKE_MOTION_MATERIAL_CHUNK_COUNT = 12; const QUAKE_MOTION_MATERIAL_DEFER_FRAME_COUNT = 2; const QUAKE_MOTION_MATERIAL_TEXTURED_AREA_RATIO = 0.25; const QUAKE_RENDER_BUNDLE_TEXTURE_PRELOAD_STATUS = "Texture images"; +const QUAKE_RENDER_BUNDLE_FLOOR_TEXTURE_PRELOAD_STATUS = "Floor textures"; const QUAKE_RENDER_BUNDLE_TEXTURE_PRELOAD_CONCURRENCY = 48; const QUAKE_RENDER_BUNDLE_STYLESHEET_ONLY_PROPERTIES = new Set([ "image-rendering", @@ -734,6 +735,22 @@ export async function preloadQuakeRenderBundleAssets( } } +export async function preloadQuakeRenderBundleFloorAssets( + renderBundle: QuakePreparedRenderBundle, + mapName: string, + progress?: QuakeRenderBundlePreloadProgress, +): Promise { + const urls = quakeRenderBundleFloorAssetUrls(renderBundle, mapName); + if (!urls.length) return; + const hasUncachedAsset = urls.some((url) => !renderBundleAssetPreloads.has(url)); + const complete = hasUncachedAsset ? progress?.startTask(QUAKE_RENDER_BUNDLE_FLOOR_TEXTURE_PRELOAD_STATUS) : null; + try { + await preloadQuakeRenderBundleAssetUrls(urls); + } finally { + complete?.(); + } +} + export function exposeQuakeRenderBundleAtlasPages( element: HTMLElement, renderBundle: QuakePreparedRenderBundle, @@ -1286,6 +1303,14 @@ export function quakeRenderBundlePreloadAssetUrls(renderBundle: QuakePreparedRen return [...urls]; } +export function quakeRenderBundleFloorAssetUrls(renderBundle: QuakePreparedRenderBundle, mapName: string): string[] { + const normalizedMapName = mapName.trim().toLowerCase(); + if (!normalizedMapName) return []; + const floorPrefix = `pc-${normalizedMapName}-floor-`; + return quakeRenderBundlePreloadAssetUrls(renderBundle) + .filter((url) => quakeRenderBundleUrlBasename(url).startsWith(floorPrefix)); +} + function collectQuakeRenderBundleInlineAssetUrls( urls: Set, styleText: string, @@ -1311,6 +1336,11 @@ function normalizeQuakeRenderBundleCssUrl(value: string): string { return url.trim(); } +function quakeRenderBundleUrlBasename(url: string): string { + const path = url.split(/[?#]/, 1)[0] ?? ""; + return path.slice(path.lastIndexOf("/") + 1); +} + function preloadQuakeRenderBundleStyle(renderBundle: QuakePreparedRenderBundle): Promise { const key = quakeRenderBundleStyleKey(renderBundle); if (!key) return Promise.resolve(); diff --git a/src/runtime/shootables/enemyMovement.ts b/src/runtime/shootables/enemyMovement.ts index fa1d4c0..5da99dd 100644 --- a/src/runtime/shootables/enemyMovement.ts +++ b/src/runtime/shootables/enemyMovement.ts @@ -393,6 +393,7 @@ export function createQuakeEnemyMovementRuntime( profile: QuakeMonsterCombatProfile, canSeePlayer: boolean, ): boolean { + if ((profile.chaseSpeed ?? 0) <= 0) return false; const stopDistance = canSeePlayer ? Math.max(profile.chaseStopDistance ?? profile.range * 0.72, PLAYER_RADIUS * 1.45) : 0; diff --git a/src/runtime/world.ts b/src/runtime/world.ts index a3b0f79..0f9be46 100644 --- a/src/runtime/world.ts +++ b/src/runtime/world.ts @@ -434,13 +434,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } const mountedLeafCountBefore = countMountedQuakeLeaves(); const planningStartedAt = performance.now(); - const leafMountRequests: Array<[QuakeFaceLeaf, boolean]> = []; - let scannedFaceLeafCount = 0; - for (const [faceIndex, leaves] of faceLeaves) { - const visible = visibleFaces.has(faceIndex); - scannedFaceLeafCount += leaves.length; - for (const leaf of leaves) leafMountRequests.push([leaf, visible]); - } + const { leafMountRequests, scannedFaceLeafCount } = buildQuakeLeafVisibilityMountRequests(faceLeaves, visibleFaces); const planningMs = performance.now() - planningStartedAt; visibleFaceKey = nextKey; visibleLeafIndex = nextLeafIndex; @@ -1488,6 +1482,22 @@ function normalizedQuakeLeafVisibilityFaceIndexes(metadata: QuakePreparedRenderB .sort((a, b) => a - b); } +export function buildQuakeLeafVisibilityMountRequests( + faceLeaves: Map, + visibleFaces: Set, +): { leafMountRequests: Map; scannedFaceLeafCount: number } { + const leafMountRequests = new Map(); + let scannedFaceLeafCount = 0; + for (const [faceIndex, leaves] of faceLeaves) { + const visible = visibleFaces.has(faceIndex); + scannedFaceLeafCount += leaves.length; + for (const leaf of leaves) { + leafMountRequests.set(leaf, visible || leafMountRequests.get(leaf) === true); + } + } + return { leafMountRequests, scannedFaceLeafCount }; +} + function clampedIntegerParam(value: string | null, fallback: number, min: number, max: number): number { if (value === null) return fallback; const parsed = Number.parseInt(value, 10); diff --git a/test/runtime/mobileControls.test.mjs b/test/runtime/mobileControls.test.mjs index 69ef804..69573ec 100644 --- a/test/runtime/mobileControls.test.mjs +++ b/test/runtime/mobileControls.test.mjs @@ -36,6 +36,39 @@ test("mobile move stick handles pointer input and updates the visible nub", () = } }); +test("mobile move stick uses the touch-down point as the neutral anchor", () => { + const harness = createMobileControlsHarness(); + try { + const startX = harness.centerX - 30; + const startY = harness.centerY + 20; + harness.moveZone.dispatchEvent(pointer(harness.window, "pointerdown", startX, startY, 12, 1)); + assert.deepEqual(harness.analogSamples.at(-1), [0, 0]); + assert.equal(harness.stick.style.left, `${startX - 18}px`); + assert.equal(harness.stick.style.top, `${startY - 100}px`); + assert.equal(harness.front.style.transform, "translate(0px, 0px)"); + + harness.moveZone.dispatchEvent(pointer(harness.window, "pointermove", startX, startY - 72, 12, 1)); + assert.deepEqual(harness.analogSamples.at(-1), [0, 1]); + assert.equal(harness.front.style.transform, "translate(0px, -27px)"); + + harness.moveZone.dispatchEvent(pointer(harness.window, "pointerup", startX, startY - 72, 12, 0)); + assertMoveReleased(harness); + assert.equal(harness.stick.style.left, "72px"); + assert.equal(harness.stick.style.top, "72px"); + + const secondStartX = harness.centerX + 24; + const secondStartY = harness.centerY - 18; + harness.moveZone.dispatchEvent(pointer(harness.window, "pointerdown", secondStartX, secondStartY, 13, 1)); + assert.deepEqual(harness.analogSamples.at(-1), [0, 0]); + assert.equal(harness.stick.style.left, `${secondStartX - 18}px`); + assert.equal(harness.stick.style.top, `${secondStartY - 100}px`); + harness.moveZone.dispatchEvent(pointer(harness.window, "pointerup", secondStartX, secondStartY, 13, 0)); + assertMoveReleased(harness); + } finally { + harness.restore(); + } +}); + test("mobile move stick clears on cancellation, lost capture, and explicit app cleanup", () => { const harness = createMobileControlsHarness(); try { @@ -205,8 +238,8 @@ function assertVisualReleased(harness) { } function assertMoveVisualGeometry(harness) { - assert.equal(harness.stick.style.left, "50%"); - assert.equal(harness.stick.style.top, "50%"); + assert.equal(harness.stick.style.left, "72px"); + assert.equal(harness.stick.style.top, "72px"); assert.equal(harness.stick.style.width, "108px"); assert.equal(harness.stick.style.height, "108px"); assert.equal(harness.stick.style.marginLeft, "-54px"); diff --git a/test/runtime/renderBundlePreloadUrls.test.mjs b/test/runtime/renderBundlePreloadUrls.test.mjs index b415a74..9dcf546 100644 --- a/test/runtime/renderBundlePreloadUrls.test.mjs +++ b/test/runtime/renderBundlePreloadUrls.test.mjs @@ -7,6 +7,7 @@ import { importTsModule } from "../importTsModule.mjs"; const { quakeRenderBundleElementAssetUrls, + quakeRenderBundleFloorAssetUrls, quakeRenderBundlePreloadAssetUrls, } = await importTsModule("src/runtime/renderBundleMesh.ts"); @@ -48,6 +49,22 @@ test("render bundles without complete asset URLs fail before runtime preload", ( ); }); +test("map floor preloads select only direct floor component assets", () => { + const urls = quakeRenderBundleFloorAssetUrls(renderBundle({ + assetUrls: [ + "/q/b/e1m1/a0.avif", + "/q/b/e1m1/pc-e1m1-floor-p658-s0-ground1_2-c164.png", + "/q/b/e1m1/pc-e1m1-ceiling-p75-s1-tech01_6-c43.png", + "/q/b/e1m1/pc-e1m1-wall-p12-s0-metal1_2-c8.png", + "/q/b/e1m2/pc-e1m2-floor-p10-s0-ground1_2-c1.png", + "/q/b/e1m1/l3251s.png", + ], + assetUrlsComplete: true, + }), "E1M1"); + + assert.deepEqual(urls, ["/q/b/e1m1/pc-e1m1-floor-p658-s0-ground1_2-c164.png"]); +}); + 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"); diff --git a/test/runtime/worldVisibilityMountRequests.test.mjs b/test/runtime/worldVisibilityMountRequests.test.mjs new file mode 100644 index 0000000..4a2f176 --- /dev/null +++ b/test/runtime/worldVisibilityMountRequests.test.mjs @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const { + buildQuakeLeafVisibilityMountRequests, +} = await importTsModule("src/runtime/world.ts"); + +test("merged world leaves stay mounted when any indexed face is visible", () => { + const mergedLeaf = { leafIndex: 2804 }; + const hiddenLeaf = { leafIndex: 2020 }; + const faceLeaves = new Map([ + [3345, [mergedLeaf]], + [3346, [mergedLeaf]], + [4286, [mergedLeaf]], + [2395, [hiddenLeaf]], + ]); + + const { leafMountRequests, scannedFaceLeafCount } = buildQuakeLeafVisibilityMountRequests( + faceLeaves, + new Set([3345]), + ); + + assert.equal(scannedFaceLeafCount, 4); + assert.equal(leafMountRequests.get(mergedLeaf), true); + assert.equal(leafMountRequests.get(hiddenLeaf), false); +});