Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/runtime/app/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 })]
Expand Down
57 changes: 38 additions & 19 deletions src/runtime/mobileControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -297,6 +307,7 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions):
x: event.clientX,
y: event.clientY,
});
setMoveAnchor(event);
try {
moveZone?.setPointerCapture(event.pointerId);
} catch {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand All @@ -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%";
Expand All @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions src/runtime/renderBundleMesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -734,6 +735,22 @@ export async function preloadQuakeRenderBundleAssets(
}
}

export async function preloadQuakeRenderBundleFloorAssets(
renderBundle: QuakePreparedRenderBundle,
mapName: string,
progress?: QuakeRenderBundlePreloadProgress,
): Promise<void> {
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,
Expand Down Expand Up @@ -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<string>,
styleText: string,
Expand All @@ -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<void> {
const key = quakeRenderBundleStyleKey(renderBundle);
if (!key) return Promise.resolve();
Expand Down
1 change: 1 addition & 0 deletions src/runtime/shootables/enemyMovement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
24 changes: 17 additions & 7 deletions src/runtime/world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1488,6 +1482,22 @@ function normalizedQuakeLeafVisibilityFaceIndexes(metadata: QuakePreparedRenderB
.sort((a, b) => a - b);
}

export function buildQuakeLeafVisibilityMountRequests(
faceLeaves: Map<number, QuakeFaceLeaf[]>,
visibleFaces: Set<number>,
): { leafMountRequests: Map<QuakeFaceLeaf, boolean>; scannedFaceLeafCount: number } {
const leafMountRequests = new Map<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.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);
Expand Down
37 changes: 35 additions & 2 deletions test/runtime/mobileControls.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down
17 changes: 17 additions & 0 deletions test/runtime/renderBundlePreloadUrls.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { importTsModule } from "../importTsModule.mjs";

const {
quakeRenderBundleElementAssetUrls,
quakeRenderBundleFloorAssetUrls,
quakeRenderBundlePreloadAssetUrls,
} = await importTsModule("src/runtime/renderBundleMesh.ts");

Expand Down Expand Up @@ -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");
Expand Down
28 changes: 28 additions & 0 deletions test/runtime/worldVisibilityMountRequests.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading