From 41c637ff624c160a40102f6050015ca01a48c144 Mon Sep 17 00:00:00 2001 From: agustin-littlehat Date: Mon, 22 Jun 2026 11:01:56 -0300 Subject: [PATCH] Restore prepared explosion sprite assets --- src/prepare/assets.mjs | 51 +++++- src/prepare/effectSprites.mjs | 234 +++++++++++++++++++++++++ src/runtime/app/session.ts | 7 +- test/prepare/effectSprites.test.mjs | 96 ++++++++++ test/runtime/effectSpriteFlow.test.mjs | 111 +++++++----- 5 files changed, 440 insertions(+), 59 deletions(-) create mode 100644 src/prepare/effectSprites.mjs create mode 100644 test/prepare/effectSprites.test.mjs diff --git a/src/prepare/assets.mjs b/src/prepare/assets.mjs index e8c8dd6..bd6c387 100644 --- a/src/prepare/assets.mjs +++ b/src/prepare/assets.mjs @@ -17,6 +17,7 @@ import { deterministicAtlasDebugSourceImagesSymbol, replaceQuakeRenderBundleWorldAtlas, } from "./deterministicAtlas.mjs"; +import { prepareQuakeEffectSprites } from "./effectSprites.mjs"; import { applyQuakeWorldPlanarComponents } from "./worldPlanarComponents.mjs"; import { QUAKE_UNIT_SCALE } from "../quakeScale.js"; @@ -120,11 +121,13 @@ const weaponModelOutputDir = path.join(quakeOutputDir, "w"); const pickupOutputPath = path.join(quakeOutputDir, "pickups.json"); const progsOutputPath = path.join(quakeOutputDir, "progs.json"); const soundManifestOutputPath = path.join(quakeOutputDir, "sounds.json"); +const effectSpritesOutputPath = path.join(quakeOutputDir, "effects.json"); const sourceProgramFactsInputPath = path.join(projectRoot, "src/generated/quakeProgramFacts.json"); const QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH = "progs/v_shot.mdl"; const sourcePath = path.join(projectRoot, "src/prepare/scene.ts"); const textureOutputDir = path.join(quakeAssetOutputDir, "t"); const soundOutputDir = path.join(quakeOutputDir, "s"); +const effectSpritesOutputDir = path.join(quakeAssetOutputDir, "e"); const renderBundleOutputDir = path.join(quakeAssetOutputDir, "b"); const quakeRenderBundleAvifQuality = Number.parseInt(process.env.QUAKE_RENDER_BUNDLE_AVIF_QUALITY ?? "70", 10); const quakeRenderBundleAvifEffort = Number.parseInt(process.env.QUAKE_RENDER_BUNDLE_AVIF_EFFORT ?? "4", 10); @@ -609,6 +612,7 @@ try { await rm(path.join(quakeOutputDir, "t"), { recursive: true, force: true }); await rm(path.join(quakeOutputDir, "b"), { recursive: true, force: true }); await rm(path.join(quakeOutputDir, "p"), { recursive: true, force: true }); + await rm(path.join(quakeOutputDir, "e"), { recursive: true, force: true }); await copyStaticPublicAssets(); } @@ -931,12 +935,20 @@ try { await writeFile(pickupOutputPath, JSON.stringify(pickupModels)); const soundManifest = await runPrepareStep("sounds", () => exportQuakeSounds(pak, parseQuakePakDirectory)); await writeFile(soundManifestOutputPath, JSON.stringify(soundManifest)); + const effectSprites = await runPrepareStep("effect sprites", () => prepareQuakeEffectSprites({ + outputDir: effectSpritesOutputDir, + pak, + parsePakDirectory: parseQuakePakDirectory, + publicUrlForOutputPath: generatedPublicUrl, + })); + await writeFile(effectSpritesOutputPath, JSON.stringify(effectSprites)); await writeFileAtomic(manifestOutputPath, JSON.stringify(buildQuakeAssetManifest( preparedMaps, programMetadata, pickupModels, soundManifest, sourceProgramFacts, + effectSprites, ))); await pruneUnreferencedTextureFiles([ ...preparedMaps.map((item) => item.outputPath), @@ -979,6 +991,7 @@ try { console.log(`Wrote ${path.relative(projectRoot, progsOutputPath)}`); console.log(`Wrote ${path.relative(projectRoot, pickupOutputPath)}`); console.log(`Wrote ${path.relative(projectRoot, soundManifestOutputPath)} (${Object.keys(soundManifest.sounds).length} sounds)`); + console.log(`Wrote ${path.relative(projectRoot, effectSpritesOutputPath)} (${Object.keys(effectSprites.sprites).length} sprites)`); console.log(`Wrote ${path.relative(projectRoot, manifestOutputPath)}`); } } @@ -2358,7 +2371,14 @@ async function loadQuakeSourceProgramFacts() { } } -function buildQuakeAssetManifest(preparedMaps, programMetadata, pickupModels, soundManifest, sourceProgramFacts = null) { +function buildQuakeAssetManifest( + preparedMaps, + programMetadata, + pickupModels, + soundManifest, + sourceProgramFacts = null, + effectSprites = null, +) { const preparedModelPaths = new Set(Object.keys(pickupModels?.models ?? {})); const preparedSoundPaths = new Set(Object.keys(soundManifest?.sounds ?? {})); const maps = preparedMaps.map(({ mapName, mapPath, outputPath, prepared }) => { @@ -2384,18 +2404,20 @@ function buildQuakeAssetManifest(preparedMaps, programMetadata, pickupModels, so }; }); const mapNames = new Set(maps.map((map) => map.mapName)); + const assets = { + weaponModelUrl: generatedPublicUrl(weaponOutputPath), + weaponModelUrls: quakeWeaponModelUrlMap(sourceProgramFacts), + pickupModelsUrl: generatedPublicUrl(pickupOutputPath), + programMetadataUrl: generatedPublicUrl(progsOutputPath), + soundManifestUrl: generatedPublicUrl(soundManifestOutputPath), + ...(effectSprites ? { effectSpritesUrl: generatedPublicUrl(effectSpritesOutputPath) } : {}), + }; return { version: 1, assetRoot: quakePublicPath, startMap: mapNames.has(quakeStartMap) ? quakeStartMap : maps[0]?.mapName ?? quakeStartMap, maps, - assets: { - weaponModelUrl: generatedPublicUrl(weaponOutputPath), - weaponModelUrls: quakeWeaponModelUrlMap(sourceProgramFacts), - pickupModelsUrl: generatedPublicUrl(pickupOutputPath), - programMetadataUrl: generatedPublicUrl(progsOutputPath), - soundManifestUrl: generatedPublicUrl(soundManifestOutputPath), - }, + assets, }; } @@ -2405,10 +2427,11 @@ function quakeMultiplayerPlayerModelPathsFromPreparedModels(preparedModelPaths) } async function writeQuakeAssetManifestFromGeneratedFiles() { - const [programMetadata, pickupModels, soundManifest] = await Promise.all([ + const [programMetadata, pickupModels, soundManifest, effectSprites] = await Promise.all([ readQuakeGeneratedJson(progsOutputPath, "program metadata"), readQuakeGeneratedJson(pickupOutputPath, "pickup models"), readQuakeGeneratedJson(soundManifestOutputPath, "sound manifest"), + readOptionalQuakeGeneratedJson(effectSpritesOutputPath, "effect sprites"), ]); const sourceProgramFacts = await loadQuakeSourceProgramFacts(); const preparedMaps = []; @@ -2426,10 +2449,20 @@ async function writeQuakeAssetManifestFromGeneratedFiles() { pickupModels, soundManifest, sourceProgramFacts, + effectSprites, ))); console.log(`Wrote ${path.relative(projectRoot, manifestOutputPath)} from existing generated assets`); } +async function readOptionalQuakeGeneratedJson(outputPath, label) { + try { + return await readQuakeGeneratedJson(outputPath, label); + } catch (error) { + if (error?.message?.startsWith?.(`Missing generated ${label} `)) return null; + throw error; + } +} + async function readQuakeGeneratedJson(outputPath, label) { try { return JSON.parse(await readFile(outputPath, "utf8")); diff --git a/src/prepare/effectSprites.mjs b/src/prepare/effectSprites.mjs new file mode 100644 index 0000000..c776478 --- /dev/null +++ b/src/prepare/effectSprites.mjs @@ -0,0 +1,234 @@ +import { createHash } from "node:crypto"; +import { mkdir, rm } from "node:fs/promises"; +import path from "node:path"; + +import sharp from "sharp"; + +const QUAKE_SPRITE_MAGIC = 0x50534449; // IDSP +const QUAKE_SPRITE_VERSION = 1; +const QUAKE_SPRITE_HEADER_BYTES = 36; +const QUAKE_SPRITE_SINGLE_FRAME = 0; +const QUAKE_SPRITE_TRANSPARENT_INDEX = 255; +const QUAKE_PALETTE_PATH = "gfx/palette.lmp"; +const QUAKE_EXPLOSION_SPRITE_PATH = "progs/s_explod.spr"; +const QUAKE_EXPLOSION_FRAME_DURATION_MS = 100; + +export async function prepareQuakeEffectSprites({ + outputDir, + pak, + parsePakDirectory, + publicUrlForOutputPath, +} = {}) { + if (!outputDir) throw new Error("Missing Quake effect sprite output directory."); + if (!pak) throw new Error("Missing Quake PAK bytes for effect sprite prepare."); + if (typeof parsePakDirectory !== "function") throw new Error("Missing Quake PAK directory parser."); + if (typeof publicUrlForOutputPath !== "function") throw new Error("Missing Quake public URL mapper."); + + await rm(outputDir, { recursive: true, force: true }); + await mkdir(outputDir, { recursive: true }); + + const entriesByName = new Map(parsePakDirectory(pak).map((entry) => [String(entry.name).toLowerCase(), entry])); + const paletteEntry = entriesByName.get(QUAKE_PALETTE_PATH); + if (!paletteEntry) throw new Error(`Quake PAK is missing ${QUAKE_PALETTE_PATH}.`); + const spriteEntry = entriesByName.get(QUAKE_EXPLOSION_SPRITE_PATH); + if (!spriteEntry) throw new Error(`Quake PAK is missing ${QUAKE_EXPLOSION_SPRITE_PATH}.`); + + const palette = quakePakEntryBytes(pak, paletteEntry); + const spriteBytes = quakePakEntryBytes(pak, spriteEntry); + const sprite = parseQuakeSprite(spriteBytes, QUAKE_EXPLOSION_SPRITE_PATH); + const sheet = quakeEffectSpriteSheetRgba(sprite, palette); + const sourceHash = createHash("sha256").update(spriteBytes).digest("hex"); + const outputPath = path.join(outputDir, `s_explod-${sourceHash.slice(0, 12)}.png`); + await sharp(sheet.rgba, { + raw: { + width: sheet.width, + height: sheet.height, + channels: 4, + }, + }).png().toFile(outputPath); + + const asset = quakeEffectSpriteAsset({ + frameDurationMs: QUAKE_EXPLOSION_FRAME_DURATION_MS, + kind: "explosion", + sourceHash, + sourcePath: QUAKE_EXPLOSION_SPRITE_PATH, + sprite, + texture: { + alphaMode: "quake-sprite-index-255-alpha", + height: sheet.height, + transparentPixels: sheet.transparentPixels, + url: publicUrlForOutputPath(outputPath), + visiblePixels: sheet.visiblePixels, + width: sheet.width, + }, + }); + + return { + version: 1, + schema: "cssquake-effect-sprites@1", + explosionSprite: QUAKE_EXPLOSION_SPRITE_PATH, + sprites: { + [QUAKE_EXPLOSION_SPRITE_PATH]: asset, + }, + }; +} + +export function parseQuakeSprite(bytes, sourcePath = "sprite.spr") { + const data = bytesView(bytes); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + if (data.byteLength < QUAKE_SPRITE_HEADER_BYTES) { + throw new Error(`${sourcePath} is too small to be a Quake sprite.`); + } + const magic = view.getUint32(0, true); + if (magic !== QUAKE_SPRITE_MAGIC) throw new Error(`${sourcePath} has invalid sprite magic.`); + const version = view.getInt32(4, true); + if (version !== QUAKE_SPRITE_VERSION) throw new Error(`${sourcePath} has unsupported sprite version ${version}.`); + + const header = { + type: view.getInt32(8, true), + boundingRadius: view.getFloat32(12, true), + maxWidth: view.getInt32(16, true), + maxHeight: view.getInt32(20, true), + numFrames: view.getInt32(24, true), + beamLength: view.getFloat32(28, true), + syncType: view.getInt32(32, true), + }; + if (header.numFrames <= 0 || header.maxWidth <= 0 || header.maxHeight <= 0) { + throw new Error(`${sourcePath} has invalid sprite dimensions or frame count.`); + } + + let offset = QUAKE_SPRITE_HEADER_BYTES; + const frames = []; + for (let index = 0; index < header.numFrames; index++) { + requireBytes(data, offset, 4, `${sourcePath} frame ${index} type`); + const frameType = view.getInt32(offset, true); + offset += 4; + if (frameType !== QUAKE_SPRITE_SINGLE_FRAME) { + throw new Error(`${sourcePath} frame ${index} uses unsupported grouped sprite frame type ${frameType}.`); + } + requireBytes(data, offset, 16, `${sourcePath} frame ${index} header`); + const originX = view.getInt32(offset, true); + const originY = view.getInt32(offset + 4, true); + const width = view.getInt32(offset + 8, true); + const height = view.getInt32(offset + 12, true); + offset += 16; + if (width <= 0 || height <= 0) throw new Error(`${sourcePath} frame ${index} has invalid size ${width}x${height}.`); + const pixelCount = width * height; + requireBytes(data, offset, pixelCount, `${sourcePath} frame ${index} pixels`); + frames.push({ + index, + originX, + originY, + width, + height, + pixels: data.slice(offset, offset + pixelCount), + }); + offset += pixelCount; + } + if (offset !== data.byteLength) { + throw new Error(`${sourcePath} has ${data.byteLength - offset} trailing sprite bytes.`); + } + + return { + sourcePath, + header, + frames, + }; +} + +export function quakeEffectSpriteSheetRgba(sprite, paletteBytes) { + const palette = bytesView(paletteBytes); + if (palette.byteLength < 256 * 3) throw new Error("Quake palette must contain 256 RGB entries."); + const frames = Array.isArray(sprite?.frames) ? sprite.frames : []; + if (!frames.length) throw new Error("Quake sprite has no frames."); + const frameWidth = Math.max(1, sprite.header?.maxWidth ?? Math.max(...frames.map((frame) => frame.width))); + const frameHeight = Math.max(1, sprite.header?.maxHeight ?? Math.max(...frames.map((frame) => frame.height))); + const width = frameWidth * frames.length; + const height = frameHeight; + const rgba = Buffer.alloc(width * height * 4); + let transparentPixels = 0; + let visiblePixels = 0; + + for (const frame of frames) { + const frameX = frame.index * frameWidth; + for (let y = 0; y < frame.height; y++) { + for (let x = 0; x < frame.width; x++) { + const paletteIndex = frame.pixels[y * frame.width + x]; + const outOffset = ((y * width) + frameX + x) * 4; + if (paletteIndex === QUAKE_SPRITE_TRANSPARENT_INDEX) { + rgba[outOffset + 3] = 0; + transparentPixels++; + continue; + } + const paletteOffset = paletteIndex * 3; + rgba[outOffset] = palette[paletteOffset]; + rgba[outOffset + 1] = palette[paletteOffset + 1]; + rgba[outOffset + 2] = palette[paletteOffset + 2]; + rgba[outOffset + 3] = 255; + visiblePixels++; + } + } + } + + return { + frameHeight, + frameWidth, + height, + rgba, + transparentPixels, + visiblePixels, + width, + }; +} + +export function quakeEffectSpriteAsset({ + frameDurationMs, + kind, + sourceHash, + sourcePath, + sprite, + texture, +}) { + const frames = sprite.frames.map((frame) => ({ + index: frame.index, + x: frame.index * sprite.header.maxWidth, + y: 0, + width: frame.width, + height: frame.height, + originX: frame.originX, + originY: frame.originY, + xoff: frame.originX, + yoff: -frame.originY, + })); + return { + id: "s_explod", + kind, + sourcePath, + sourceHash, + header: sprite.header, + frameCount: frames.length, + frameDurationMs, + texture, + frames, + }; +} + +function quakePakEntryBytes(pak, entry) { + const data = bytesView(pak); + const offset = Number(entry.offset); + const size = Number(entry.size); + if (!Number.isInteger(offset) || !Number.isInteger(size) || offset < 0 || size < 0 || offset + size > data.byteLength) { + throw new Error(`Invalid Quake PAK entry bounds for ${entry.name}.`); + } + return data.slice(offset, offset + size); +} + +function bytesView(bytes) { + if (bytes instanceof Uint8Array) return bytes; + if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes); + throw new Error("Expected byte buffer."); +} + +function requireBytes(bytes, offset, count, label) { + if (offset + count > bytes.byteLength) throw new Error(`${label} extends past end of file.`); +} diff --git a/src/runtime/app/session.ts b/src/runtime/app/session.ts index 62decd3..6b19d7b 100644 --- a/src/runtime/app/session.ts +++ b/src/runtime/app/session.ts @@ -316,14 +316,17 @@ function normalizeQuakeAssetManifestAssets(value: unknown): QuakeAssetManifest[" if (normalizedModelUrl) weaponModelUrls[normalizedModelPath] = normalizedModelUrl; } } - return { + const assets: QuakeAssetManifest["assets"] = { weaponModelUrl, weaponModelUrls, pickupModelsUrl: typeof value.pickupModelsUrl === "string" ? value.pickupModelsUrl : fallback.pickupModelsUrl, programMetadataUrl: typeof value.programMetadataUrl === "string" ? value.programMetadataUrl : fallback.programMetadataUrl, - effectSpritesUrl: typeof value.effectSpritesUrl === "string" ? value.effectSpritesUrl : fallback.effectSpritesUrl, soundManifestUrl: typeof value.soundManifestUrl === "string" ? value.soundManifestUrl : fallback.soundManifestUrl, }; + if (typeof value.effectSpritesUrl === "string" && value.effectSpritesUrl.trim()) { + assets.effectSpritesUrl = value.effectSpritesUrl; + } + return assets; } function isRecord(value: unknown): value is Record { diff --git a/test/prepare/effectSprites.test.mjs b/test/prepare/effectSprites.test.mjs new file mode 100644 index 0000000..3b32d4a --- /dev/null +++ b/test/prepare/effectSprites.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseQuakeSprite, + quakeEffectSpriteAsset, + quakeEffectSpriteSheetRgba, +} from "../../src/prepare/effectSprites.mjs"; + +test("Quake effect sprite parser preserves source frame offsets and alpha index", () => { + const spriteBytes = makeTestSprite([ + { + originX: -1, + originY: 1, + width: 2, + height: 2, + pixels: [255, 1, 2, 3], + }, + { + originX: -1, + originY: 1, + width: 2, + height: 2, + pixels: [4, 255, 5, 6], + }, + ]); + const palette = makePalette(); + + const sprite = parseQuakeSprite(spriteBytes, "progs/test.spr"); + assert.equal(sprite.header.numFrames, 2); + assert.equal(sprite.header.maxWidth, 2); + assert.equal(sprite.header.maxHeight, 2); + assert.deepEqual(sprite.frames.map((frame) => [frame.originX, frame.originY]), [[-1, 1], [-1, 1]]); + + const sheet = quakeEffectSpriteSheetRgba(sprite, palette); + assert.equal(sheet.width, 4); + assert.equal(sheet.height, 2); + assert.equal(sheet.transparentPixels, 2); + assert.equal(sheet.visiblePixels, 6); + assert.equal(sheet.rgba[3], 0); + assert.deepEqual([...sheet.rgba.slice(4, 8)], [1, 2, 3, 255]); + + const asset = quakeEffectSpriteAsset({ + frameDurationMs: 100, + kind: "explosion", + sourceHash: "test-hash", + sourcePath: "progs/test.spr", + sprite, + texture: { + url: "/q/e/test.png", + width: sheet.width, + height: sheet.height, + }, + }); + assert.equal(asset.frameCount, 2); + assert.deepEqual(asset.frames.map((frame) => [frame.x, frame.y, frame.xoff, frame.yoff]), [ + [0, 0, -1, -1], + [2, 0, -1, -1], + ]); +}); + +function makeTestSprite(frames) { + const frameBytes = frames.reduce((sum, frame) => sum + 4 + 16 + frame.width * frame.height, 0); + const buffer = Buffer.alloc(36 + frameBytes); + buffer.write("IDSP", 0, "ascii"); + buffer.writeInt32LE(1, 4); + buffer.writeInt32LE(2, 8); + buffer.writeFloatLE(2, 12); + buffer.writeInt32LE(Math.max(...frames.map((frame) => frame.width)), 16); + buffer.writeInt32LE(Math.max(...frames.map((frame) => frame.height)), 20); + buffer.writeInt32LE(frames.length, 24); + buffer.writeFloatLE(0, 28); + buffer.writeInt32LE(0, 32); + let offset = 36; + for (const frame of frames) { + buffer.writeInt32LE(0, offset); + buffer.writeInt32LE(frame.originX, offset + 4); + buffer.writeInt32LE(frame.originY, offset + 8); + buffer.writeInt32LE(frame.width, offset + 12); + buffer.writeInt32LE(frame.height, offset + 16); + offset += 20; + Buffer.from(frame.pixels).copy(buffer, offset); + offset += frame.width * frame.height; + } + return buffer; +} + +function makePalette() { + const palette = Buffer.alloc(256 * 3); + for (let index = 0; index < 256; index++) { + palette[index * 3] = index; + palette[index * 3 + 1] = index + 1; + palette[index * 3 + 2] = index + 2; + } + return palette; +} diff --git a/test/runtime/effectSpriteFlow.test.mjs b/test/runtime/effectSpriteFlow.test.mjs index 897da8b..e655bb8 100644 --- a/test/runtime/effectSpriteFlow.test.mjs +++ b/test/runtime/effectSpriteFlow.test.mjs @@ -36,6 +36,52 @@ const EFFECTS_MANIFEST = { }, }; +test("effect sprite flow skips optional preload when no manifest URL is configured", async () => { + const previousDocument = globalThis.document; + const previousFetch = globalThis.fetch; + const previousWindow = globalThis.window; + const window = new Window(); + let fetchCalls = 0; + + Object.defineProperty(globalThis, "document", { + configurable: true, + value: window.document, + }); + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => { + fetchCalls += 1; + throw new Error("Effect sprite preload should not fetch without a manifest URL."); + }, + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: window, + }); + + try { + const layer = document.createElement("div"); + const flow = createQuakeEffectSpriteFlow({ + cameraPerspectiveStyle: () => "400px", + canShow: () => true, + effectSpritesUrl: () => undefined, + isGameplayPaused: () => false, + layer, + now: () => 1000, + viewOrigin: () => [0, 0, 0], + viewRotation: () => ({ rotX: 90, rotY: 270 }), + }); + + assert.equal(await flow.preload(), false); + assert.equal(fetchCalls, 0); + flow.dispose(); + } finally { + restoreGlobal("document", previousDocument); + restoreGlobal("fetch", previousFetch); + restoreGlobal("window", previousWindow); + } +}); + test("effect sprite flow preloads and animates the prepared s_explod sheet", async () => { const previousCancelAnimationFrame = globalThis.cancelAnimationFrame; const previousDocument = globalThis.document; @@ -161,53 +207,22 @@ test("effect sprite flow preloads and animates the prepared s_explod sheet", asy flow.dispose(); assert.equal(layer.children.length, 0); } finally { - if (previousCancelAnimationFrame === undefined) { - delete globalThis.cancelAnimationFrame; - } else { - Object.defineProperty(globalThis, "cancelAnimationFrame", { - configurable: true, - value: previousCancelAnimationFrame, - }); - } - if (previousDocument === undefined) { - delete globalThis.document; - } else { - Object.defineProperty(globalThis, "document", { - configurable: true, - value: previousDocument, - }); - } - if (previousFetch === undefined) { - delete globalThis.fetch; - } else { - Object.defineProperty(globalThis, "fetch", { - configurable: true, - value: previousFetch, - }); - } - if (previousPerformance === undefined) { - delete globalThis.performance; - } else { - Object.defineProperty(globalThis, "performance", { - configurable: true, - value: previousPerformance, - }); - } - if (previousRequestAnimationFrame === undefined) { - delete globalThis.requestAnimationFrame; - } else { - Object.defineProperty(globalThis, "requestAnimationFrame", { - configurable: true, - value: previousRequestAnimationFrame, - }); - } - if (previousWindow === undefined) { - delete globalThis.window; - } else { - Object.defineProperty(globalThis, "window", { - configurable: true, - value: previousWindow, - }); - } + restoreGlobal("cancelAnimationFrame", previousCancelAnimationFrame); + restoreGlobal("document", previousDocument); + restoreGlobal("fetch", previousFetch); + restoreGlobal("performance", previousPerformance); + restoreGlobal("requestAnimationFrame", previousRequestAnimationFrame); + restoreGlobal("window", previousWindow); } }); + +function restoreGlobal(name, value) { + if (value === undefined) { + delete globalThis[name]; + return; + } + Object.defineProperty(globalThis, name, { + configurable: true, + value, + }); +}