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
74 changes: 70 additions & 4 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { markQuakeTrace } from "./runtime/debug/traceMarks";
import { shouldSpawnQuakeEntityForCurrentGame, shouldSpawnQuakeEntityForGameMode } from "./runtime/entities";
import {
applyQuakeInventoryDelta,
changeQuakeInventoryWeaponByImpulse,
createQuakeHudElements,
selectQuakeBestInventoryWeapon,
type QuakeKey,
Expand Down Expand Up @@ -140,6 +141,7 @@ import {
fetchQuakeScene,
type QuakeAssetManifest,
type QuakeMapLoadOptions,
type QuakeSceneMode,
} from "./runtime/app/session";
import { createQuakePointHazardFlow } from "./runtime/app/pointHazardFlow";
import {
Expand Down Expand Up @@ -223,6 +225,7 @@ import {
quakeWeaponProjectileModelPath,
type QuakeWeaponFireEvent,
type QuakeWeaponFireSoundId,
type QuakeWeaponShootableTarget,
type QuakeWeaponWallImpactEffect,
type QuakeWeaponProjectileVisualHandle,
} from "./runtime/weapons";
Expand Down Expand Up @@ -910,14 +913,26 @@ function currentQuakeCssView(): QuakeCssView {

function shouldSpawnQuakeShootableForCurrentMode(entity: QuakeEntity): boolean {
if (QUAKE_MULTIPLAYER_ENABLED && entity.classname.startsWith("monster_")) return false;
return shouldSpawnQuakeEntityForCurrentGame(entity);
return shouldSpawnQuakeEntityForCurrentMode(entity);
}

function shouldSpawnQuakePickupForCurrentMode(entity: QuakeEntity): boolean {
return shouldSpawnQuakeEntityForCurrentMode(entity);
}

function shouldSpawnQuakeEntityForCurrentMode(entity: QuakeEntity): boolean {
if (QUAKE_MULTIPLAYER_ENABLED) return shouldSpawnQuakeEntityForGameMode(entity, { deathmatch: true });
return shouldSpawnQuakeEntityForCurrentGame(entity);
}

function quakeSceneModeForCurrentMode(): QuakeSceneMode {
return QUAKE_MULTIPLAYER_ENABLED ? "deathmatch" : "singleplayer";
}

function quakeSceneUrlForCurrentMode(mapName: string): string | undefined {
return quakeAssetCatalog.sceneUrl(mapName, quakeSceneModeForCurrentMode());
}

function currentQuakeViewUrl(): string {
return quakeRoute.currentViewUrl();
}
Expand Down Expand Up @@ -1385,6 +1400,7 @@ let quakePlayerLifecycle!: QuakePlayerLifecycleFlow;
let quakePointerGameplay!: ReturnType<typeof createQuakePointerGameplayFlow>;
const quakeGameplayInput = createQuakeGameplayInputFlow({
canUseGameplayInput: canUseQuakeGameplayInput,
changeWeaponByImpulse: changeQuakePlayerWeaponByImpulse,
clearMobileLookInput: () => quakePointerGameplay.clearMobileLookInput(),
clearMobileMoveInput: () => quakePointerGameplay.clearMobileMoveInput(),
debugFlyEnabled: () => quakeDebugFly.isEnabled(),
Expand Down Expand Up @@ -1955,6 +1971,7 @@ const weapons = createQuakeWeaponsController({
hasViewmodel: viewmodel.hasWeapon,
getCollisionWorld: () => currentCollisionWorld,
getEntities: () => entityByIndex,
getDamageableBrushTargets: quakeDamageableBrushWeaponTargets,
getShootables: shootables.weaponTargets,
getPlayerEyeHeight: () => getPlayer().eyeHeight(),
getPlayerWaterLevel: () =>
Expand Down Expand Up @@ -2161,6 +2178,38 @@ let quakeMultiplayerLastReconciledInputSequence = 0;
let quakeMultiplayerLastInventoryFingerprint: string | null = null;
let quakeMultiplayerApplyingWorldEvent = false;
const quakeMultiplayerPickupRequestAt = new Map<number, number>();

function* quakeDamageableBrushWeaponTargets(): Iterable<QuakeWeaponShootableTarget> {
const sceneResult = currentResult;
if (!sceneResult) return;
for (const entry of quakeDamageableBrushes.snapshot().brushes) {
if (entry.health <= 0) continue;
const entity = entityByIndex.get(entry.entityIndex);
if (!entity || entity.classname !== "func_button" || entity.modelIndex === undefined) continue;
const model = sceneResult.models.find((item) => item.index === entity.modelIndex);
if (!model) continue;
const min: Vec3 = [
(model.mins.x - quakeModelPivot.x) * QUAKE_COLLISION_UNIT_SCALE,
(model.mins.y - quakeModelPivot.y) * QUAKE_COLLISION_UNIT_SCALE,
(model.mins.z - quakeModelPivot.z) * QUAKE_COLLISION_UNIT_SCALE,
];
const max: Vec3 = [
(model.maxs.x - quakeModelPivot.x) * QUAKE_COLLISION_UNIT_SCALE,
(model.maxs.y - quakeModelPivot.y) * QUAKE_COLLISION_UNIT_SCALE,
(model.maxs.z - quakeModelPivot.z) * QUAKE_COLLISION_UNIT_SCALE,
];
yield {
entity,
dead: false,
origin: [
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
(min[2] + max[2]) * 0.5,
],
bounds: { min, max },
};
}
}
const quakeMultiplayerWorldRequestAt = new Map<string, number>();
let quakeMultiplayerLastRoomEvent: Record<string, unknown> | null = null;
let quakeMultiplayerRecentRoomEvents: Record<string, unknown>[] = [];
Expand Down Expand Up @@ -2436,6 +2485,22 @@ function syncQuakeHud(): void {
quakeHudFlow.sync();
}

function changeQuakePlayerWeaponByImpulse(impulse: number): boolean {
if (!player || !canUseQuakeGameplayInput()) return false;
const result = changeQuakeInventoryWeaponByImpulse(player.inventory(), impulse);
if (!result) return false;
if (result.message) {
quakeTextPresentation.notify(result.message);
return true;
}
if (result.changed) {
syncQuakeHud();
viewmodel.syncTransform();
syncQuakeCrosshairTarget();
}
return true;
}

function flashQuakeBonusOverlay(): void {
quakeHudFlow.flashBonusOverlay();
}
Expand Down Expand Up @@ -3443,7 +3508,7 @@ function quakeMapLoadView(options: QuakeMapLoadOptions): QuakeCssView | null {

function currentQuakeMultiplayerRoomKey(): QuakeMultiplayerRoomCompatibilityKey | null {
if (!currentResult) return null;
const sceneUrl = quakeAssetCatalog.sceneUrl(currentMapName);
const sceneUrl = quakeSceneUrlForCurrentMode(currentMapName);
if (!sceneUrl) return null;
return {
mapName: currentMapName,
Expand Down Expand Up @@ -5304,7 +5369,7 @@ async function loadQuake(): Promise<void> {
routeFromLocation: quakeUrlRouteFromLocation,
routeIsDirect: quakeUrlRouteIsDirect,
routeShouldNormalize: quakeUrlRouteShouldNormalize,
sceneUrl: quakeAssetCatalog.sceneUrl,
sceneUrl: quakeSceneUrlForCurrentMode,
setAssetManifest: setQuakeAssetManifest,
setCurrentMapName: (mapName) => {
currentMapName = mapName;
Expand Down Expand Up @@ -5461,7 +5526,7 @@ const quakeMapLoader = createQuakeAppMapLoader<QuakeCssView, QuakeViewmodelModel
preloadSceneAssets: preloadQuakeSceneModelRenderBundleAssets,
preloadWeapon: (progress) => quakeViewmodelAssets.preload(progress),
resumeGameplayAfterMapLoad: resumeQuakeGameplayAfterMapLoad,
sceneUrl: quakeAssetCatalog.sceneUrl,
sceneUrl: quakeSceneUrlForCurrentMode,
setGameplayStarted: setQuakeGameplayStarted,
setLoading: setQuakeLoading,
syncUrlView: applyQuakeUrlView,
Expand All @@ -5483,6 +5548,7 @@ const quakeInput = createQuakeAppInputController({
handleDebugFlyKey: quakeDebugFly.handleKey,
handleMenuKeyDown: (event) => menu.handleKeyDown(event),
handleMoveKey: quakeGameplayInput.handleMoveKey,
handleWeaponKey: quakeGameplayInput.handleWeaponKey,
hidePersistedLoadingConsole: hidePersistedQuakeLoadingConsole,
isEditableTarget: quakeGameplayInput.isEditableTarget,
isLoading: () => quakeAppLoading,
Expand Down
115 changes: 101 additions & 14 deletions src/prepare/assets.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { build } from "esbuild";
import { execSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { access, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { availableParallelism, tmpdir } from "node:os";
import path from "node:path";
Expand All @@ -18,6 +18,11 @@ import {
replaceQuakeRenderBundleWorldAtlas,
} from "./deterministicAtlas.mjs";
import { prepareQuakeEffectSprites } from "./effectSprites.mjs";
import {
QUAKE_PREPARED_SCENE_MODES,
quakePreparedSceneModeOutputPath,
quakePreparedSceneVariant,
} from "./sceneVariants.mjs";
import { applyQuakeWorldPlanarComponents } from "./worldPlanarComponents.mjs";
import { QUAKE_UNIT_SCALE } from "../quakeScale.js";

Expand Down Expand Up @@ -831,23 +836,44 @@ try {
for (const item of preparedMaps) {
const { outputPath, prepared } = item;
const mapName = item.mapName ?? mapNameFromPakPath(item.mapPath);
const preparedJson = await runPrepareDetailStep(`map ${mapName} json stringify`, () => JSON.stringify(prepared));
await runPrepareDetailStep(`map ${mapName} json mkdir`, () => mkdir(path.dirname(outputPath), { recursive: true }));
await runPrepareDetailStep(`map ${mapName} json write`, () => writeFile(outputPath, preparedJson));
item.size = Buffer.byteLength(preparedJson);
item.modeOutputPaths = {};
item.modeSizes = {};
for (const mode of QUAKE_PREPARED_SCENE_MODES) {
const modeOutputPath = quakePreparedSceneModeOutputPath(outputPath, mode);
const modePrepared = await runPrepareDetailStep(`map ${mapName} ${mode} scene variant`, () =>
quakePreparedSceneVariant(prepared, mode)
);
const preparedJson = await runPrepareDetailStep(`map ${mapName} ${mode} json stringify`, () =>
JSON.stringify(modePrepared)
);
await runPrepareDetailStep(`map ${mapName} ${mode} json mkdir`, () =>
mkdir(path.dirname(modeOutputPath), { recursive: true })
);
await runPrepareDetailStep(`map ${mapName} ${mode} json write`, () => writeFile(modeOutputPath, preparedJson));
item.modeOutputPaths[mode] = modeOutputPath;
item.modeSizes[mode] = Buffer.byteLength(preparedJson);
}
item.size = item.modeSizes.singleplayer;
}

if (quakePrepareMapOnly) {
await pruneUnreferencedTextureFiles(
preparedMaps.map((item) => item.outputPath),
quakePreparedMapOutputPaths(preparedMaps),
{ removeUnreferenced: false },
);
await writeFileAtomic(
manifestOutputPath,
stableManifestJson ?? JSON.stringify(buildQuakeAssetManifest(preparedMaps, {}, { models: {} })),
stableManifestJson
? quakeMapOnlyManifestJsonWithSceneVariants(stableManifestJson, preparedMaps)
: JSON.stringify(buildQuakeAssetManifest(preparedMaps, {}, { models: {} })),
);
for (const { outputPath, prepared, size } of preparedMaps) {
console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(size)})`);
for (const { modeOutputPaths, modeSizes, prepared, size } of preparedMaps) {
for (const mode of QUAKE_PREPARED_SCENE_MODES) {
const outputPath = modeOutputPaths?.[mode];
const modeSize = modeSizes?.[mode];
if (!outputPath || !modeSize) continue;
console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(modeSize)})`);
}
console.log(`${prepared.label}: ${prepared.faceCount}/${prepared.sourceFaceCount} faces, ${prepared.textureCount} textures`);
}
console.log(
Expand Down Expand Up @@ -951,12 +977,17 @@ try {
effectSprites,
)));
await pruneUnreferencedTextureFiles([
...preparedMaps.map((item) => item.outputPath),
...quakePreparedMapOutputPaths(preparedMaps),
...weaponModelOutputPaths,
pickupOutputPath,
]);
for (const { outputPath, prepared, size } of preparedMaps) {
console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(size)})`);
for (const { modeOutputPaths, modeSizes, prepared } of preparedMaps) {
for (const mode of QUAKE_PREPARED_SCENE_MODES) {
const outputPath = modeOutputPaths?.[mode];
const size = modeSizes?.[mode];
if (!outputPath || !size) continue;
console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(size)})`);
}
console.log(`${prepared.label}: ${prepared.faceCount}/${prepared.sourceFaceCount} faces, ${prepared.textureCount} textures`);
}
console.log(`Wrote ${path.relative(projectRoot, hudBaseOutputPath)}`);
Expand Down Expand Up @@ -2381,7 +2412,7 @@ function buildQuakeAssetManifest(
) {
const preparedModelPaths = new Set(Object.keys(pickupModels?.models ?? {}));
const preparedSoundPaths = new Set(Object.keys(soundManifest?.sounds ?? {}));
const maps = preparedMaps.map(({ mapName, mapPath, outputPath, prepared }) => {
const maps = preparedMaps.map(({ mapName, mapPath, outputPath, prepared, modeOutputPaths }) => {
const hasGameLogicFacts = Boolean(prepared?.gameLogic);
const gameLogicModelPaths = quakeGameLogicMapModelPaths(prepared, preparedModelPaths);
const gameLogicSoundPaths = quakeGameLogicMapSoundPaths(prepared, preparedSoundPaths);
Expand All @@ -2393,11 +2424,16 @@ function buildQuakeAssetManifest(
if (!modelPaths.includes(playerModelPath)) modelPaths.push(playerModelPath);
}
modelPaths.sort();
const sceneUrls = Object.fromEntries(
Object.entries(modeOutputPaths ?? { singleplayer: outputPath })
.map(([mode, modeOutputPath]) => [mode, generatedPublicUrl(modeOutputPath)]),
);
return {
mapName,
title: quakeMapTitles.get(mapName) ?? mapName.toUpperCase(),
pakPath: mapPath,
sceneUrl: generatedPublicUrl(outputPath),
sceneUrl: sceneUrls.singleplayer ?? generatedPublicUrl(outputPath),
...(sceneUrls.deathmatch ? { sceneUrls } : {}),
selectable: quakeSelectableMapNames.has(mapName),
modelPaths,
...(gameLogicSoundPaths.length ? { soundPaths: gameLogicSoundPaths } : {}),
Expand All @@ -2421,6 +2457,41 @@ function buildQuakeAssetManifest(
};
}

function quakePreparedMapOutputPaths(preparedMaps) {
return preparedMaps.flatMap((item) =>
item.modeOutputPaths
? Object.values(item.modeOutputPaths)
: [item.outputPath]
);
}

function quakeMapOnlyManifestJsonWithSceneVariants(stableManifestJson, preparedMaps) {
const manifest = JSON.parse(stableManifestJson);
const mapsByName = new Map(preparedMaps.map((item) => [item.mapName, item]));
manifest.maps = manifest.maps.map((map) => {
const item = mapsByName.get(map.mapName);
if (!item) return map;
const sceneUrls = Object.fromEntries(
Object.entries(item.modeOutputPaths ?? { singleplayer: item.outputPath })
.map(([mode, outputPath]) => [mode, generatedPublicUrl(outputPath)]),
);
return {
...map,
sceneUrl: sceneUrls.singleplayer ?? map.sceneUrl,
...(sceneUrls.deathmatch
? { sceneUrls: { ...quakeManifestMapSceneUrls(map), ...sceneUrls } }
: {}),
};
});
return JSON.stringify(manifest);
}

function quakeManifestMapSceneUrls(map) {
return map?.sceneUrls && typeof map.sceneUrls === "object" && !Array.isArray(map.sceneUrls)
? map.sceneUrls
: {};
}

function quakeMultiplayerPlayerModelPathsFromPreparedModels(preparedModelPaths) {
return QUAKE_MULTIPLAYER_PLAYER_ALIAS_MODEL_PATHS
.filter((modelPath) => preparedModelPaths.has(modelPath));
Expand All @@ -2436,10 +2507,16 @@ async function writeQuakeAssetManifestFromGeneratedFiles() {
const sourceProgramFacts = await loadQuakeSourceProgramFacts();
const preparedMaps = [];
for (const [mapPath, outputPath] of mapOutputPaths) {
const modeOutputPaths = { singleplayer: outputPath };
const deathmatchOutputPath = quakePreparedSceneModeOutputPath(outputPath, "deathmatch");
if (await generatedFileExists(deathmatchOutputPath)) {
modeOutputPaths.deathmatch = deathmatchOutputPath;
}
preparedMaps.push({
mapName: mapNameFromPakPath(mapPath),
mapPath,
outputPath,
modeOutputPaths,
prepared: await readQuakeGeneratedJson(outputPath, `${mapNameFromPakPath(mapPath)} map`),
});
}
Expand All @@ -2463,6 +2540,16 @@ async function readOptionalQuakeGeneratedJson(outputPath, label) {
}
}

async function generatedFileExists(outputPath) {
try {
await access(outputPath);
return true;
} catch (error) {
if (error?.code === "ENOENT") return false;
throw error;
}
}

async function readQuakeGeneratedJson(outputPath, label) {
try {
return JSON.parse(await readFile(outputPath, "utf8"));
Expand Down
Loading
Loading