Skip to content
Draft
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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ jobs:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
- run: pnpm test
- run: pnpm build
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
"test:browser": "node test/browser/runBrowserFixtures.mjs",
"test:harness": "node test/runHarnessPlan.mjs",
"test:perf": "node test/perf/runPerfPreflight.mjs",
"test:dev": "pnpm test && pnpm test:perf",
"test:all": "pnpm test && pnpm test:assets && pnpm test:browser:smoke && pnpm test:browser && pnpm test:perf"
"test:dev": "pnpm typecheck && pnpm test && pnpm test:perf",
"test:all": "pnpm typecheck && pnpm test && pnpm test:assets && pnpm test:browser:smoke && pnpm test:browser && pnpm test:perf",
"typecheck": "node test/typescript/check.mjs",
"typecheck:all": "tsc --noEmit"
},
"dependencies": {
"@layoutit/polycss": "^0.2.6",
Expand All @@ -42,6 +44,7 @@
"partykit": "0.0.115",
"playwright": "^1.60.0",
"sharp": "^0.34.5",
"typescript": "5.9.3",
"vite": "^7.3.1"
}
}
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

219 changes: 109 additions & 110 deletions src/App.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/runtime/app/debugApi.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { QuakeMapLoadResult } from "./mapLoadOwnership";
import type { Vec3 } from "@layoutit/polycss";

import type { QuakeDebugRecorder } from "../debug/recording";
Expand Down Expand Up @@ -25,7 +26,7 @@ export interface QuakeAppDebugApiOptions {
fireballEmittersCount(): number;
fireballsCount(): number;
forwardDirection(rotX: number, rotY: number): Vec3;
loadMap(mapName: string): Promise<void>;
loadMap(mapName: string): Promise<QuakeMapLoadResult>;
mapExists(mapName: string): boolean;
pointToPoly(point: { x: number; y: number; z: number }): Vec3;
renderOrigin(): Vec3;
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/app/entityActivationFlow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "./mapLoadOwnership";
import type { Vec3 } from "@layoutit/polycss";

import type { QuakeGameLogicFacts } from "../../prepare/gameLogicFacts";
Expand Down Expand Up @@ -49,7 +50,7 @@ export interface QuakeEntityActivationFlowOptions {
currentGameLogic(): QuakeGameLogicFacts | null | undefined;
entities(): ReadonlyMap<number, QuakeEntity>;
getOrigin(): [number, number, number];
loadMap(mapName: string, options: { loadingStatus: string; resumeGameplay: boolean }): Promise<void>;
loadMap(mapName: string, options: { loadingStatus: string; resumeGameplay: boolean }): Promise<QuakeMapLoadResult>;
mapExists(mapName: string): boolean;
intermission: QuakeEntityActivationIntermission;
movers: Pick<QuakeMoversController, "activateEntity" | "forceDoorsDownAfter" | "get">;
Expand Down Expand Up @@ -444,6 +445,7 @@ export function createQuakeEntityActivationFlow(
try {
await options.loadMap(nextMap, { loadingStatus: "Loading", resumeGameplay: true });
} catch (error) {
if (!quakeMapLoadFailureIsCurrent(error)) return true;
console.error(error);
options.text.setCenterPrint(`COULD NOT LOAD ${nextMap.toUpperCase()}`);
}
Expand Down
37 changes: 24 additions & 13 deletions src/runtime/app/loadingFlow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { QuakeMapLoadResult } from "./mapLoadOwnership";
import {
QUAKE_ASSETS_REGENERATING_ACTION,
QUAKE_ASSETS_REGENERATING_STATUS,
Expand Down Expand Up @@ -60,8 +61,9 @@ export interface QuakeLoadingFlow {
clearDeathOverlay(): void;
completeSceneReadiness<T>(
modelPromise: Promise<T>,
mountModel: (modelPromise: Promise<T>) => Promise<void>,
mountModel: (modelPromise: Promise<T>, isCurrent: () => boolean) => Promise<void>,
progress?: QuakeLoadingProgressTracker,
isCurrent?: () => boolean,
): Promise<void>;
createProgressTracker(status?: string): QuakeLoadingProgressTracker;
handleGameplayStarted(started: boolean): void;
Expand All @@ -79,7 +81,8 @@ export interface QuakeLoadingFlow {
export interface QuakeLoadingStartupOptions {
fetchManifest(): Promise<QuakeAssetManifest>;
initializedLine: string;
loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise<void>;
onReady(): void;
loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise<QuakeMapLoadResult>;
loadPickupModels(progress?: QuakeLoadingProgressTracker): Promise<void>;
loadProgramMetadata(progress?: QuakeLoadingProgressTracker): Promise<void>;
pakLine: string;
Expand Down Expand Up @@ -133,30 +136,35 @@ export function createQuakeLoadingFlow(options: QuakeLoadingFlowOptions): QuakeL
} finally {
completeManifestTask();
}
const startupRoute = startup.routeFromLocation();
const startMap = startupRoute.mapName;
if (!startup.sceneUrl(startMap)) throw new Error(`No prepared Quake start map registered for ${startMap}.`);
const programMetadataPromise = startup.loadProgramMetadata(progress);
const pickupModelsPromise = startup.loadPickupModels(progress);
const weaponPromise = startup.preloadWeapon(progress);
if (hasPakAssets) loadingConsole.queueLine(startup.pakLine);
await Promise.all([programMetadataPromise, pickupModelsPromise, weaponPromise]);
if (options.isDisposed()) return;
// Finish bootstrap before accepting map requests; read the latest browser route.
const preparedRoute = startup.routeFromLocation();
if (!startup.routeIsDirect(preparedRoute) && !(preparedRoute.mapParamPresent && !preparedRoute.mapParamValid)) {
loadingConsole.queueLine(startup.initializedLine);
await loadingConsole.waitForQueue();
if (options.isDisposed()) return;
}
startup.onReady();
const startupRoute = startup.routeFromLocation();
const startMap = startupRoute.mapName;
if (!startup.sceneUrl(startMap)) throw new Error(`No prepared Quake start map registered for ${startMap}.`);
startup.setCurrentMapName(startMap);
startup.setMenuCurrentLevel(startMap);
const shouldPrimeInvalidMapFallback = startupRoute.mapParamPresent && !startupRoute.mapParamValid;
if (startup.routeIsDirect(startupRoute) || shouldPrimeInvalidMapFallback) {
await startup.loadMap(startMap, {
const loaded = await startup.loadMap(startMap, {
urlMode: startup.routeIsDirect(startupRoute) && startup.routeShouldNormalize(startupRoute) ? "replace" : "none",
view: startup.routeIsDirect(startupRoute) ? startupRoute.view : null,
});
if (options.isDisposed()) return;
if (!loaded || !loaded.isCurrent() || options.isDisposed()) return;
startup.syncRoutePresentation(startupRoute, { preferMenu: shouldPrimeInvalidMapFallback });
return;
}
loadingConsole.queueLine(startup.initializedLine);
await loadingConsole.waitForQueue();
if (options.isDisposed()) return;
setLoading(false);
if (options.isDisposed()) return;
startup.syncRoutePresentation(startupRoute);
Expand Down Expand Up @@ -316,13 +324,16 @@ export function createQuakeLoadingFlow(options: QuakeLoadingFlowOptions): QuakeL

async function completeSceneReadiness<T>(
modelPromise: Promise<T>,
mountModel: (modelPromise: Promise<T>) => Promise<void>,
mountModel: (modelPromise: Promise<T>, isCurrent: () => boolean) => Promise<void>,
progress?: QuakeLoadingProgressTracker,
isCurrent: () => boolean = () => true,
): Promise<void> {
await mountModel(modelPromise);
if (options.isDisposed()) return;
if (options.isDisposed() || !isCurrent()) return;
await mountModel(modelPromise, isCurrent);
if (options.isDisposed() || !isCurrent()) return;
const completeReadinessTask = progress?.startTask("Rendered first frame");
const readiness = await waitForReadiness();
if (options.isDisposed() || !isCurrent()) return;
completeReadinessTask?.();
const completeFunReminderTask = progress?.startTask("Don't forget to have fun!");
completeFunReminderTask?.();
Expand Down
22 changes: 22 additions & 0 deletions src/runtime/app/mapLoadOwnership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/** A completed load can lose ownership before an awaiting caller resumes. */
export interface QuakeMapLoadCompletion {
isCurrent(): boolean;
}

export type QuakeMapLoadResult = QuakeMapLoadCompletion | false;

/** Keep failure ownership alive through async wrappers and catch callbacks too. */
export class QuakeMapLoadFailure extends Error {
constructor(cause: unknown, readonly isCurrent: () => boolean) {
super(cause instanceof Error ? cause.message : String(cause), { cause });
this.name = "QuakeMapLoadFailure";
}
}

export function quakeMapLoadFailureIsCurrent(error: unknown): boolean {
return !(error instanceof QuakeMapLoadFailure) || error.isCurrent();
}

export function quakeMapLoadFailureCause(error: unknown): unknown {
return error instanceof QuakeMapLoadFailure ? error.cause : error;
}
16 changes: 10 additions & 6 deletions src/runtime/app/playerLifecycleFlow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { QuakeMapLoadResult } from "./mapLoadOwnership";
import type { QuakeScene } from "../../types/quake";
import type { QuakePlayerController, QuakePlayerDeathDetails, QuakePlayerDeathResult } from "../player";
import type { QuakeMapLoadOptions } from "./session";
Expand Down Expand Up @@ -29,6 +30,7 @@ export interface QuakePlayerLifecycleFlowOptions {
controls: QuakePlayerLifecycleControls;
currentCollisionWorld(): unknown | null;
currentMapName(): string;
currentLoad(): QuakeMapLoadResult;
currentResult(): QuakeScene | null;
exitPointerLockIfHost(): void;
focusHost(): void;
Expand All @@ -39,7 +41,7 @@ export interface QuakePlayerLifecycleFlowOptions {
isMainMenuOpen(): boolean;
isMenuPanelOpen(): boolean;
jumpVelocity: number;
loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise<void>;
loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise<QuakeMapLoadResult>;
player(): Pick<QuakePlayerController, "respawn">;
playDeathSound?: (soundPath: string) => boolean;
pointerTrace(kind: string, details: Record<string, unknown>): void;
Expand Down Expand Up @@ -82,7 +84,7 @@ export interface QuakePlayerLifecycleFlow {
shouldOpenMainMenuOnControlsEnd(): boolean;
shouldResumeMainMenuOnEscape(): boolean;
showPlayerDeath(details?: QuakePlayerDeathDetails): QuakePlayerDeathResult | void;
startNewGame(): Promise<void>;
startNewGame(): Promise<QuakeMapLoadResult>;
suppressMainMenuOnResumeControlsEnd(): void;
}

Expand Down Expand Up @@ -265,16 +267,17 @@ export function createQuakePlayerLifecycleFlow(
return true;
}

async function startNewGame(): Promise<void> {
async function startNewGame(): Promise<QuakeMapLoadResult> {
const mapName = options.currentResult() ? options.currentMapName() : options.startMap();
if (!options.currentResult()) {
await options.loadMap(mapName, {
const loaded = options.currentLoad();
if (!options.currentResult() || !loaded) {
return options.loadMap(mapName, {
loadingStatus: `World ${mapName}.bsp`,
preserveLoadingConsole: true,
urlMode: "push",
});
return;
}
if (!loaded.isCurrent()) return false;
options.clearMegahealthRot();
options.clearPowerups();
options.clearMoveInput();
Expand All @@ -283,6 +286,7 @@ export function createQuakePlayerLifecycleFlow(
clearLevelComplete();
options.player().respawn();
options.setGameplayStarted(true);
return loaded;
}

function resumeGameplayAfterMapLoad(): void {
Expand Down
22 changes: 16 additions & 6 deletions src/runtime/app/routeFlow.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { quakeMapLoadFailureCause, quakeMapLoadFailureIsCurrent, type QuakeMapLoadResult } from "./mapLoadOwnership";
import {
parseQuakeUrlRouteFromLocation,
quakeUrlForMapView,
Expand All @@ -17,14 +18,15 @@ export interface QuakeCssView {

export interface QuakeRouteFlowOptions<TView> {
applyView(view: TView): void;
canLoadMap(): boolean;
clearStartupState(): void;
currentMapName(): string;
currentView(): TView;
hasCurrentScene(): boolean;
hideMainMenu(): void;
isDisposed(): boolean;
isLoading(): boolean;
loadMap(mapName: string, options: QuakeMapLoadOptions): Promise<void>;
loadMap(mapName: string, options: QuakeMapLoadOptions): Promise<QuakeMapLoadResult>;
mapExists(mapName: string): boolean;
menuEnabled: boolean;
compactMultiplayerInviteMapName?: (inviteId: string) => string | null;
Expand Down Expand Up @@ -53,6 +55,8 @@ export interface QuakeRouteFlow<TView> {
export function createQuakeRouteFlow<TView>(
options: QuakeRouteFlowOptions<TView>,
): QuakeRouteFlow<TView> {
let navigationGeneration = 0;

function routeFromLocation(): QuakeUrlRoute {
return parseQuakeUrlRouteFromLocation(window.location, {
compactMultiplayerInviteMapName: options.compactMultiplayerInviteMapName,
Expand Down Expand Up @@ -125,9 +129,10 @@ export function createQuakeRouteFlow<TView>(
}

function handlePopState(): void {
if (options.isDisposed() || options.isLoading()) return;
if (options.isDisposed() || !options.canLoadMap()) return;
++navigationGeneration;
const route = routeFromLocation();
if (options.currentMapName() === route.mapName && options.hasCurrentScene()) {
if (!options.isLoading() && options.currentMapName() === route.mapName && options.hasCurrentScene()) {
const currentRouteView = routeView(route);
if (currentRouteView) {
options.applyView(currentRouteView);
Expand All @@ -145,13 +150,18 @@ export function createQuakeRouteFlow<TView>(
}

function navigateToRoute(route: QuakeUrlRoute): void {
const generation = navigationGeneration;
const isCurrent = () => !options.isDisposed() && generation === navigationGeneration;
void options.loadMap(route.mapName, { urlMode: "none", view: route.view })
.then(() => {
if (!options.isDisposed()) syncPresentation(route);
.then((loaded) => {
if (loaded && loaded.isCurrent() && isCurrent() && !options.isLoading() && options.currentMapName() === route.mapName) {
syncPresentation(route);
}
})
.catch((error) => {
if (!isCurrent() || !quakeMapLoadFailureIsCurrent(error)) return;
error = quakeMapLoadFailureCause(error);
console.error(error);
if (options.isDisposed()) return;
if (error instanceof QuakeAssetsRegeneratingError) {
options.setAssetsRegenerating(error.message);
} else {
Expand Down
18 changes: 11 additions & 7 deletions src/runtime/app/saveSession.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { QuakeMapLoadResult } from "./mapLoadOwnership";
import {
createCssQuakeSaveSlot as createCssQuakeSaveSlotV1,
readCssQuakeSaveSlot,
Expand All @@ -11,7 +12,7 @@ type CssQuakeSaveSlotInput = Parameters<typeof createCssQuakeSaveSlotV1>[0];
export interface CssQuakeSaveSessionController {
canLoad(): boolean;
canSave(): boolean;
load(): Promise<void>;
load(): Promise<QuakeMapLoadResult>;
save(): void;
}

Expand All @@ -31,9 +32,10 @@ export interface CssQuakeSaveSessionOptions {
clearPowerupTimers(): void;
clearWeaponViewPunch(): void;
currentMapName(): string;
currentLoad(): QuakeMapLoadResult;
currentOrigin(): [number, number, number];
hasCurrentScene(mapName?: string): boolean;
loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise<void>;
loadMap(mapName: string, options?: QuakeMapLoadOptions): Promise<QuakeMapLoadResult>;
mapExists(mapName: string): boolean;
notify(message: string): void;
resetActiveTriggers(): void;
Expand Down Expand Up @@ -107,26 +109,28 @@ export function createCssQuakeSaveSession(options: CssQuakeSaveSessionOptions):
}
}

async function load(): Promise<void> {
async function load(): Promise<QuakeMapLoadResult> {
const slot = readCssQuakeSaveSlot();
if (!slot || !options.mapExists(slot.mapName)) {
options.notify("No saved game");
return;
return false;
}
options.clearAttackInput();
options.clearMoveInput();
options.clearMobileMoveInput();
if (!options.hasCurrentScene(slot.mapName)) {
await options.loadMap(slot.mapName, {
let loaded = options.currentLoad();
if (!loaded || !options.hasCurrentScene(slot.mapName)) {
loaded = await options.loadMap(slot.mapName, {
loadingStatus: "Loading save",
resumeGameplay: false,
urlMode: "push",
});
}
if (!options.hasCurrentScene(slot.mapName)) return;
if (!loaded || !loaded.isCurrent() || !options.hasCurrentScene(slot.mapName)) return false;
applySaveSlot(slot);
options.trace("progress-load", { mapName: slot.mapName, savedAt: slot.savedAt });
options.notify("Game loaded");
return loaded;
}

function applySaveSlot(slot: CssQuakeSaveSlotV1): void {
Expand Down
Loading
Loading