diff --git a/LICENSE b/LICENSE index 2d8ec93..e2b3c45 100644 --- a/LICENSE +++ b/LICENSE @@ -351,7 +351,7 @@ repository's GPL-2.0 license. This repository does not version resource.1, pak0.pak, or generated/converted assets. Build and deploy steps generate derived assets from resource.1. -The source-port console background at src/assets/source-port-conback.lmp is by +The source-port console background at src/assets/source-port-conback.png is by AAS and is released under the Creative Commons Attribution 3.0 Unported license: https://creativecommons.org/licenses/by/3.0/legalcode diff --git a/index.html b/index.html index 4b9c825..ec737d1 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + - -
- + +
+ +
+ +
+ +
+ + + +
@@ -1895,6 +634,7 @@
+ + + +
+
@@ -2279,31 +1032,6 @@

- -

Debug alt="" /> +
@@ -2370,137 +1099,31 @@

Debug

- + +
+ +
+
diff --git a/src/App.ts b/src/App.ts index 0ef00f8..206d254 100644 --- a/src/App.ts +++ b/src/App.ts @@ -87,6 +87,10 @@ import { } from "./runtime/app/levelStatsFlow"; import { createQuakeAppInputController } from "./runtime/app/input"; import { createQuakeGameplayInputFlow } from "./runtime/app/gameplayInputFlow"; +import { + createQuakeImpactParticleFlow, + type QuakeImpactParticleFlow, +} from "./runtime/app/impactParticleFlow"; import { createQuakeDamageableBrushFlow } from "./runtime/app/damageableBrushFlow"; import { createQuakePowerupFlow } from "./runtime/app/powerupFlow"; import { createQuakeRouteFlow, type QuakeCssView } from "./runtime/app/routeFlow"; @@ -194,6 +198,7 @@ import { createQuakeWeaponsController, type QuakeWeaponFireEvent, type QuakeWeaponFireSoundId, + type QuakeWeaponWallImpactEffect, } from "./runtime/weapons"; import { createQuakeWorldController, @@ -234,8 +239,9 @@ const QUAKE_DOOR_MESSAGE_COOLDOWN_MS = 2000; const quakeDom = queryQuakeAppDom(); const { app: quakeApp, - ui: quakeUi, - viewmodelLayer, + scene: quakeSceneRoot, + menu: quakeMenu, + weapon, mainMenu, mainMenuArt, versionLabel, @@ -264,9 +270,12 @@ const { alwaysRunOption, showGunOption, dynamicLightingOption, + impactParticlesOption, + impactParticlesLayer, crosshair, crosshairOption, crosshairOptionValue, + debugStack, debugPanel, debugShowMenuOption, debugEnabledOption, @@ -278,7 +287,6 @@ const { debugFlyModeOption, debugShowOutlinesOption, debugShowLabelsOption, - debugRecordButton, debugStatElements, loadingOverlay, loadingStatus, @@ -808,6 +816,27 @@ let quakeInvertMouse = invertMouseOption?.checked ?? false; let quakeAlwaysRun = alwaysRunOption?.checked ?? false; let quakeShowGun = showGunOption?.checked ?? true; let quakeDynamicLighting = dynamicLightingOption?.checked ?? true; +let quakeImpactParticles = impactParticlesOption?.checked ?? true; + +function installInspectableQuakePolycssCamera( + sceneHandle: { applyCamera(): void }, + cameraElement: HTMLElement, +): void { + const applyCamera = sceneHandle.applyCamera.bind(sceneHandle); + sceneHandle.applyCamera = () => { + applyCamera(); + stripQuakePolycssCameraDataAttributes(cameraElement); + }; + stripQuakePolycssCameraDataAttributes(cameraElement); +} + +function stripQuakePolycssCameraDataAttributes(cameraElement: HTMLElement): void { + for (const attribute of Array.from(cameraElement.attributes)) { + if (attribute.name.startsWith("data-polycss-camera-")) { + cameraElement.removeAttribute(attribute.name); + } + } +} function quakeUrlRouteFromLocation(): QuakeUrlRoute { return quakeRoute.routeFromLocation(); @@ -1191,8 +1220,13 @@ const scene = createPolyScene(quakeApp, { autoCenter: false, }); const host = scene.cameraEl as HTMLElement; -quakeApp.insertBefore(host, viewmodelLayer ?? quakeUi); +if (quakeSceneRoot) { + quakeSceneRoot.appendChild(host); +} else { + quakeApp.insertBefore(host, weapon ?? quakeMenu); +} host.tabIndex = 0; +installInspectableQuakePolycssCamera(scene, host); // PolyCSS controls read scene.host when they are created; keep that target on the inspectable camera node. (scene as unknown as { host: HTMLElement }).host = host; const sceneElement = scene.sceneElement; @@ -1426,13 +1460,14 @@ const quakeDebugPanelFlow = createQuakeDebugPanelFlow({ debugShowLabelsOption, debugShowMenuOption, debugShowOutlinesOption, + debugStack, debugShowTexturesOption, debugStatElements, hideMainMenu: () => menu.hideMainMenu(), initialHideTextures: debugShowTexturesOption ? !debugShowTexturesOption.checked : false, initialAnimationsEnabled: quakeEnemyAnimationsEnabled, initialMode: quakeUrlBoolean("debugPolys"), - initialShowFps: debugShowFpsOption?.checked ?? true, + initialShowFps: debugShowFpsOption?.checked ?? false, initialShowLabels: debugShowLabelsOption?.checked ?? false, initialShowMenu: debugShowMenuOption?.checked ?? true, initialShowOutlines: debugShowOutlinesOption?.checked ?? false, @@ -1539,7 +1574,6 @@ const quakeDebugRecordingSnapshot = createQuakeDebugRecordingSnapshotFlow({ }); const quakeDebugRecorder = createQuakeDebugRecorder({ appVersion: __CSSQUAKE_VERSION__, - button: debugRecordButton, currentMapName: () => currentMapName, entityManifest: () => currentResult?.entityManifest ?? null, snapshot: () => quakeDebugRecordingSnapshot.capture(), @@ -1587,7 +1621,7 @@ viewmodel = createQuakeViewmodelController({ controls, getRenderOrigin: quakeCameraView.currentRenderOrigin, host, - layer: viewmodelLayer, + layer: weapon, }); const quakeViewmodelAssets = createQuakeViewmodelAssetFlow({ activeWeapon: () => player?.inventory().activeWeapon ?? null, @@ -1596,6 +1630,19 @@ const quakeViewmodelAssets = createQuakeViewmodelAssetFlow({ trace: markQuakeTrace, viewmodel, }); +const quakeImpactParticleFlow: QuakeImpactParticleFlow = impactParticlesLayer + ? createQuakeImpactParticleFlow({ + canShow: canShowQuakeImpactParticles, + isGameplayPaused: isQuakeGamePaused, + layer: impactParticlesLayer, + viewOrigin: () => controls.getOrigin(), + viewRotation: () => ({ + rotX: scene.camera.state.rotX ?? 90, + rotY: scene.camera.state.rotY ?? 270, + }), + }) + : createNoopQuakeImpactParticleFlow(); +quakeImpactParticleFlow.setEnabled(quakeImpactParticles); const quakeOptions = createQuakeOptionsFlow({ alwaysRun: () => quakeAlwaysRun, alwaysRunOption, @@ -1610,6 +1657,8 @@ const quakeOptions = createQuakeOptionsFlow({ dynamicLightingEnabled: () => quakeDynamicLighting, dynamicLightingOption, enemiesDisabled: () => quakeEnemiesDisabled, + impactParticlesEnabled: () => quakeImpactParticles, + impactParticlesOption, invertMouse: () => quakeInvertMouse, invertMouseOption, mountBitmapText: mountQuakeBitmapText, @@ -1618,6 +1667,7 @@ const quakeOptions = createQuakeOptionsFlow({ setDamageDisabled: setQuakeDamageDisabled, setDynamicLighting: setQuakeDynamicLighting, setEnemiesDisabled: setQuakeEnemiesDisabled, + setImpactParticles: setQuakeImpactParticles, setInvertMouse: setQuakeInvertMouse, setShowGun: setQuakeShowGun, setStaticLightingClass: (staticLighting) => setQuakeBodyClass("quake-static-lighting", staticLighting), @@ -1829,7 +1879,20 @@ const weapons = createQuakeWeaponsController({ canDamageTargetOrigin: (start, targetOrigin) => shootables.canDamageTargetOrigin(start, targetOrigin), damageMultiplier: () => quakePowerups.damageMultiplier(), onFire: sendQuakeMultiplayerFireIntent, + onDamageImpact: (event) => { + quakeImpactParticleFlow.spawnBlood({ + damage: event.damage, + directionHint: event.direction, + origin: event.origin, + }); + }, onHit: () => quakeWeaponPresentation.flashCrosshairHit(), + onWallImpact: (event) => { + quakeImpactParticleFlow.spawnWallImpact({ + count: quakeWallImpactParticleCount(event.effect), + origin: event.origin, + }); + }, showLightningBeam: (beam) => quakeWeaponPresentation.showLightningBeam(beam), syncCrosshairTarget: queueQuakeCrosshairTargetSync, }); @@ -1898,7 +1961,7 @@ quakeStatsOverlay = createQuakeStatsOverlayFlow({ isDisposed: () => quakeAppDisposed, isLoading: () => quakeAppLoading, isMobileAvailable: quakePointerGameplay.isMobileAvailable, - root: quakeUi ?? quakeApp, + root: quakeMenu ?? quakeApp, showFpsEnabled: () => quakeDebugPanelFlow.showFpsEnabled(), }); @@ -2383,12 +2446,45 @@ function setQuakeDynamicLighting(enabled: boolean): void { quakeOptions.syncDynamicLightingOption(); } +function setQuakeImpactParticles(enabled: boolean): void { + quakeImpactParticles = enabled; + quakeImpactParticleFlow.setEnabled(enabled); + quakeOptions.syncImpactParticlesOption(); +} + function setQuakeShowGun(enabled: boolean): void { quakeShowGun = enabled; viewmodel.setVisible(enabled); quakeOptions.syncControls(); } +function canShowQuakeImpactParticles(): boolean { + return ( + !quakeAppLoading && + currentResult !== null && + !quakePlayerDead && + !hasQuakeBodyClass("quake-level-complete") && + !hasQuakeBodyClass("quake-menu-unlocked") && + !menu.isMainMenuOpen() && + !menu.isMenuPanelOpen() + ); +} + +function createNoopQuakeImpactParticleFlow(): QuakeImpactParticleFlow { + return { + clear: () => undefined, + dispose: () => undefined, + setEnabled: () => undefined, + spawnBlood: () => undefined, + spawnWallImpact: () => undefined, + }; +} + +function quakeWallImpactParticleCount(effect: QuakeWeaponWallImpactEffect): number { + if (effect === "spike") return 2; + return 3; +} + function setQuakeDebugShowMenuOption(visible: boolean): void { quakeDebugPanelFlow.setShowMenuOption(visible); } @@ -2402,7 +2498,7 @@ function syncQuakeInteractionPresentation(): void { const debugPointerUnlocked = quakeDebugPanelFlow.isModeEnabled() && document.pointerLockElement !== host; setQuakeBodyClass("quake-debug-active", quakeDebugPanelFlow.isModeEnabled()); setQuakeBodyClass("quake-debug-pointer-unlocked", debugPointerUnlocked); - setQuakeBodyClass("quake-ui-unlocked", menuSurfaceOpen || debugPointerUnlocked); + setQuakeBodyClass("quake-menu-unlocked", menuSurfaceOpen || debugPointerUnlocked); } function handleQuakeMenuDebugToggle(): void { @@ -2427,10 +2523,14 @@ function quitQuakeToMainMenu(): void { function setQuakeDebugFlyMode(enabled: boolean): void { quakeDebugFly.setEnabled(enabled); + world.setDebugShellVisible(enabled); + world.syncVisibility(true); } function syncQuakeDebugFlyMode(): void { quakeDebugFly.syncMode(); + world.setDebugShellVisible(quakeDebugFly.isEnabled()); + world.syncVisibility(true); } function respawnQuakePlayerFromFlyMode(): boolean { @@ -4140,6 +4240,12 @@ async function completeQuakeSceneReadiness( modelPromise = quakeViewmodelAssets.preload(), progress?: QuakeLoadingProgressTracker, ): Promise { + const completeWorldTexturesTask = progress?.startTask("World textures"); + try { + await world.waitForVisibleAtlasPages(); + } finally { + completeWorldTexturesTask?.(); + } await quakeLoading.completeSceneReadiness(modelPromise, quakeViewmodelAssets.mount, progress); } @@ -4251,6 +4357,7 @@ function disposeQuakeApp(): void { quakePointerGameplay.clearAttackInput(); quakeDebugFly.dispose(); quakeCameraFeedback.dispose(); + quakeImpactParticleFlow.dispose(); quakeHudFlow.dispose(); quakeCrosshairInteraction?.dispose(); quakeOptions.dispose(); @@ -4388,14 +4495,8 @@ const quakeInput = createQuakeAppInputController({ showMainMenu: () => menu.showMainMenu(), syncViewmodelTransform: () => viewmodel.syncTransform(), toggleAudioMuted: toggleQuakeAudioMuted, - toggleDebugRecording: () => { - if (quakeDebugRecorder.isRecording()) { - quakeDebugRecorder.stop(); - } else { - quakeDebugRecorder.start(); - } - }, toggleDebugMode: toggleQuakeDebugMode, + toggleOutlineTextureMode: () => quakeDebugPanelFlow.toggleOutlineTextureMode(), }); const quakeAppRuntime = createQuakeAppRuntimeContext({ diff --git a/src/assets/cssquake-logo.png b/src/assets/cssquake-logo.png index 08dc0bd..6078deb 100644 Binary files a/src/assets/cssquake-logo.png and b/src/assets/cssquake-logo.png differ diff --git a/src/assets/source-port-conback.lmp b/src/assets/source-port-conback.lmp deleted file mode 100644 index 09d68e6..0000000 Binary files a/src/assets/source-port-conback.lmp and /dev/null differ diff --git a/src/assets/source-port-conback.png b/src/assets/source-port-conback.png new file mode 100644 index 0000000..d2a6798 Binary files /dev/null and b/src/assets/source-port-conback.png differ diff --git a/src/main.ts b/src/main.ts index cbf2d26..28e214c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,12 @@ import "./quake.css"; import { mountQuakeBitmapText } from "./runtime/bitmapText"; -import "./App"; + +const params = new URLSearchParams(window.location.search); +if (params.has("map") || params.has("view")) { + document.body.classList.remove("quake-menu-open"); + document.body.classList.add("quake-main-menu-deferred"); +} + +await import("./App"); mountQuakeBitmapText(); diff --git a/src/prepare/assets.mjs b/src/prepare/assets.mjs index 6dc99bb..c985303 100644 --- a/src/prepare/assets.mjs +++ b/src/prepare/assets.mjs @@ -56,7 +56,7 @@ const staticPublicAssets = [ [path.join(projectRoot, "src/site/sitemap.xml"), path.join(generatedPublicDir, "sitemap.xml")], ]; const menuTitleLevelSelectSourcePath = path.join(projectRoot, "src/assets/menu-title-level-select-source.png"); -const sourcePortConbackSourcePath = path.join(projectRoot, "src/assets/source-port-conback.lmp"); +const sourcePortConbackSourcePath = path.join(projectRoot, "src/assets/source-port-conback.png"); const quakeMapNames = ["start", "e1m1", "e1m2", "e1m3", "e1m4", "e1m5", "e1m6", "e1m7", "e1m8"]; const quakeRenderBundleDefaultMapNames = quakeMapNames; const quakeStartMap = "e1m1"; @@ -234,7 +234,7 @@ const QUAKE_MAIN_MENU_LEVEL_LABEL = "LEVEL SELECT"; const QUAKE_MAIN_MENU_LEVEL_LABEL_SCALE = 2; const QUAKE_PICKUP_MODEL_SCALE = QUAKE_UNIT_SCALE; const QUAKE_WEAPON_MODEL_PIVOT = parseQuakeWeaponModelPivot(process.env.QUAKE_WEAPON_MODEL_PIVOT); -const QUAKE_ENEMY_ALIAS_MODEL_RENDER_SCALE = 8; +const QUAKE_ENEMY_ALIAS_MODEL_RENDER_SCALE = 4; const QUAKE_PLAYER_ALIAS_MODEL_RENDER_SCALE = 4; const QUAKE_ANIMATION_FRAME_SET_MIN_COMMON_LEAF_RATIO = 0.95; const QUAKE_ALIAS_MERGE_MAX_NONPLANAR_DISTANCE = 0.03; @@ -617,14 +617,12 @@ try { logLevel: "silent", })); - if (!quakePrepareWeaponOnly) { - renderBundleBuilder = await runPrepareStep("render engine init", () => createQuakeRenderBundleBuilder({ - concurrency: quakePrepareModelsOnly - ? normalizedQuakeRenderBundleModelConcurrency() - : normalizedQuakeRenderBundleConcurrency(), - engine: quakeRenderBundleEngine, - })); - } + renderBundleBuilder = await runPrepareStep("render engine init", () => createQuakeRenderBundleBuilder({ + concurrency: quakePrepareModelsOnly + ? normalizedQuakeRenderBundleModelConcurrency() + : normalizedQuakeRenderBundleConcurrency(), + engine: quakeRenderBundleEngine, + })); const { buildQuakeLightstyleOverlayPolygons, @@ -640,7 +638,7 @@ try { const uiAssets = loadQuakeHudAssets(pak, parseQuakePakDirectory); const weaponModelOutputPaths = await runPrepareStep( "weapon models", - () => writeQuakeWeaponModelFiles(uiAssets, sourceProgramFacts), + () => writeQuakeWeaponModelFiles(uiAssets, sourceProgramFacts, renderBundleBuilder), ); for (const outputPath of weaponModelOutputPaths) { console.log(`Wrote ${path.relative(projectRoot, outputPath)}`); @@ -865,7 +863,7 @@ try { await writeFile(mainMenuActiveOutputPaths[index], mainMenuActivePngs[index]); } await writeFile(mainMenuCursorOutputPath, await buildQuakeMainMenuCursorPng(uiAssets)); - await writeFile(mainMenuBackgroundOutputPath, await buildLooseQpicPng(uiAssets, sourcePortConbackSourcePath)); + await writeFile(mainMenuBackgroundOutputPath, await readFile(sourcePortConbackSourcePath)); await writeFile(singlePlayerMenuOutputPath, await buildPakQpicPng(uiAssets, "gfx/sp_menu.lmp")); await writeFile(aboutOutputPath, await buildQuakeAboutPng(uiAssets)); await writeFile(menuPanelTextureOutputPath, await buildQuakeMenuPanelTexturePng(menuPanelTextureMaps)); @@ -880,7 +878,7 @@ try { const programMetadata = buildQuakeProgramMetadata(uiAssets, sourceProgramFacts); const weaponModelOutputPaths = await runPrepareStep( "weapon models", - () => writeQuakeWeaponModelFiles(uiAssets, sourceProgramFacts), + () => writeQuakeWeaponModelFiles(uiAssets, sourceProgramFacts, renderBundleBuilder), ); await writeFile(progsOutputPath, JSON.stringify(programMetadata)); const modelRenderBundleConcurrency = normalizedQuakeRenderBundleModelConcurrency(); @@ -991,6 +989,7 @@ async function createQuakeRenderBundleBuilder({ concurrency, engine }) { outlineKind, deferAssetWrites = false, primaryAssetMime = "image/avif", + textureQuality = 1, }) { const assetDir = path.join(renderBundleOutputDir, name); await rm(assetDir, { recursive: true, force: true }); @@ -1084,7 +1083,7 @@ async function createQuakeRenderBundleBuilder({ concurrency, engine }) { kind: "polycss-mesh", polycssVersion: polycssPackage.version, textureLighting: "baked", - textureQuality: 1, + textureQuality, meshHtml, ...(styleUrl ? { styleUrl } : {}), ...(styleClassName ? { styleClassName } : {}), @@ -1161,6 +1160,7 @@ async function createQuakeRenderBundleBuilder({ concurrency, engine }) { outlineKind, deferAssetWrites, primaryAssetMime, + textureQuality, }); console.log( `Built render bundle for ${name}: ${result.leafCount} leaves, ` + @@ -3106,22 +3106,6 @@ async function buildPakQpicPng(assets, pakPath) { }).png().toBuffer(); } -async function buildLooseQpicPng(assets, sourcePath) { - const qpic = await readFile(sourcePath); - const width = qpic.readInt32LE(0); - const height = qpic.readInt32LE(4); - const dataOffset = 8; - const expectedLength = dataOffset + width * height; - if (width <= 0 || height <= 0 || qpic.length < expectedLength) { - throw new Error(`Invalid Quake qpic ${path.relative(projectRoot, sourcePath)}.`); - } - const rgba = Buffer.alloc(width * height * 4); - drawIndexedImage(rgba, assets.palette, qpic, dataOffset, width, height, 0, 0, width, height); - return sharp(rgba, { - raw: { width, height, channels: 4 }, - }).png().toBuffer(); -} - async function buildCustomMenuTitlePng(sourcePath, options = {}) { const height = options.height ?? 20; const resized = await sharp(sourcePath) @@ -3383,8 +3367,8 @@ async function restoreGeneratedTextureFile(urlPath, filePath) { return true; } -async function writeQuakeWeaponModelFiles(assets, sourceProgramFacts) { - const models = buildQuakeWeaponModels(assets, sourceProgramFacts); +async function writeQuakeWeaponModelFiles(assets, sourceProgramFacts, renderBundleBuilder) { + const models = await buildQuakeWeaponModels(assets, sourceProgramFacts, renderBundleBuilder); const primaryModel = models.find((model) => model.source === QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH) ?? models[0]; const outputPaths = []; await mkdir(path.dirname(weaponOutputPath), { recursive: true }); @@ -3400,9 +3384,11 @@ async function writeQuakeWeaponModelFiles(assets, sourceProgramFacts) { return outputPaths; } -function buildQuakeWeaponModels(assets, sourceProgramFacts) { - return quakePlayerWeaponViewModelPaths(sourceProgramFacts) - .map((modelPath) => buildQuakeWeaponModel(assets, modelPath)); +function buildQuakeWeaponModels(assets, sourceProgramFacts, renderBundleBuilder) { + return Promise.all( + quakePlayerWeaponViewModelPaths(sourceProgramFacts) + .map((modelPath) => buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath)), + ); } function quakePlayerWeaponViewModelPaths(sourceProgramFacts) { @@ -3460,40 +3446,68 @@ function quakeWeaponModelUrlMap(sourceProgramFacts) { return urls; } -function buildQuakeWeaponModel(assets, modelPath = QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH) { +async function buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath = QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH) { const model = parseQuakeAliasModel(assets, modelPath); const idleFrame = model.frames[0]; + const fireFrame = model.frames[1] ?? idleFrame; + const textureBrightness = 1.5; if (!idleFrame) throw new Error("Quake weapon viewmodel has no frames."); + const texture = await encodeTextureFileUrl({ + width: model.skinWidth, + height: model.skinHeight, + pixels: quakeAliasPaddedSkin(model), + palette: assets.palette, + brightness: textureBrightness, + }); + const polygons = model.triangles.map((triangle) => { + const uvs = triangle.indices.map((index) => quakeAliasUv(model, triangle, index)); + const isNozzle = isQuakeWeaponNozzlePolygon(uvs); + const frame = isNozzle ? fireFrame : idleFrame; + const vertices = triangle.indices.map((index) => quakeWeaponVertex(frame.vertices[index])); + if (isNozzle) { + return { + vertices, + texture, + textureAlphaMode: "opaque", + uvs, + data: { nozzle: true }, + }; + } + return { + vertices, + texture, + textureAlphaMode: "opaque", + uvs, + }; + }); + const tightenWeaponDom = quakeDomTighteningEnabled("other", false); return { source: modelPath, - rasterModel: buildQuakeWeaponRasterModel(assets, model, modelPath), + renderBundle: await renderBundleBuilder.build({ + bundleName: quakeWeaponRenderBundleName(modelPath), + polygons: anchorQuakeWeaponPolygons(polygons), + extractLeafStyles: true, + tightenAtlasLeaves: tightenWeaponDom, + optimizeAtlasLeafBasis: tightenWeaponDom, + optimizeAtlasLeafHomography: tightenWeaponDom, + }), }; } -function buildQuakeWeaponRasterModel(assets, model, modelPath) { +function quakeWeaponRenderBundleName(modelPath) { + return `w/${path.basename(modelPath, path.extname(modelPath)).toLowerCase().replace(/[^a-z0-9_-]+/g, "_")}`; +} + +function anchorQuakeWeaponPolygons(polygons) { const [px, py, pz] = QUAKE_WEAPON_MODEL_PIVOT; - const frames = model.frames.slice(0, 2).map((frame) => ({ - name: frame.name, - vertices: frame.vertices.map((vertex) => { - const [x, y, z] = quakeWeaponVertex(vertex); - return [x - px, y - py, z - pz]; - }), - normalIndices: frame.normalIndices ?? [], + return polygons.map((polygon) => ({ + ...polygon, + vertices: polygon.vertices.map((vertex) => [ + vertex[0] - px, + vertex[1] - py, + vertex[2] - pz, + ]), })); - return { - version: 1, - source: modelPath, - skinWidth: model.skinWidth, - skinHeight: model.skinHeight, - skin: Buffer.from(quakeAliasPaddedSkin(model)).toString("base64"), - palette: Buffer.from(assets.palette).toString("base64"), - triangles: model.triangles.map((triangle) => ({ - facesfront: Boolean(triangle.facesfront), - indices: [...triangle.indices], - uvs: triangle.indices.map((index) => quakeAliasUv(model, triangle, index)), - })), - frames, - }; } async function buildQuakePickupModels(assets, buildBspModel, programMetadata, renderBundleBuilder, options = {}) { @@ -4248,6 +4262,16 @@ function isQuakeEntityFunctionName(name) { name === "worldspawn"; } +function isQuakeWeaponNozzlePolygon(uvs) { + const minU = Math.min(...uvs.map((uv) => uv[0])); + const maxU = Math.max(...uvs.map((uv) => uv[0])); + const maxV = Math.max(...uvs.map((uv) => uv[1])); + return maxV < 0.35 && ( + (minU < 0.22 && maxU < 0.22) || + (minU > 0.5 && maxU < 0.72) + ); +} + function loadQuakeHudAssets(pak, parsePakDirectory) { const entries = new Map(parsePakDirectory(pak).map((entry) => [ entry.name, diff --git a/src/prepare/deterministicAtlas.mjs b/src/prepare/deterministicAtlas.mjs index 3117ca1..6c6736e 100644 --- a/src/prepare/deterministicAtlas.mjs +++ b/src/prepare/deterministicAtlas.mjs @@ -492,6 +492,12 @@ async function buildDeterministicAtlasResidency(renderBundle, visibility, output visibilityLeafPages[leaf.leafIndex] = list; pagesByLeaf.set(leaf.leafIndex, list); } + addBrushModelAtlasResidencyPages({ + pagesByLeaf, + sourceFaceToPages, + visibility, + visibilityLeafPages, + }); for (const leaf of visibility.metadata.leaves) { const prewarm = new Set(pagesByLeaf.get(leaf.leafIndex) ?? []); for (const adjacentLeafIndex of leaf.adjacentLeafIndexes ?? []) { @@ -540,6 +546,47 @@ async function buildDeterministicAtlasResidency(renderBundle, visibility, output }; } +function addBrushModelAtlasResidencyPages({ + pagesByLeaf, + sourceFaceToPages, + visibility, + visibilityLeafPages, +}) { + for (const sourceFace of visibility.metadata.sourceFaces ?? []) { + if (!sourceFace.modelIndex || sourceFace.modelIndex <= 0) continue; + const pages = sourceFaceToPages.get(sourceFace.faceIndex); + if (!pages?.size) continue; + for (const leafIndex of sourceFace.leafIndexes ?? []) { + addAtlasResidencyLeafPages(visibilityLeafPages, pagesByLeaf, leafIndex, pages); + } + } + for (const blocker of visibility.metadata.doorBlockers ?? []) { + const pages = new Set(); + for (const sourceFaceIndex of blocker.faceIndexes ?? []) { + for (const pageIndex of sourceFaceToPages.get(sourceFaceIndex) ?? []) pages.add(pageIndex); + } + if (!pages.size) continue; + const leafIndexes = blocker.nearbyLeafIndexes?.length + ? blocker.nearbyLeafIndexes + : blocker.leafIndexes ?? []; + for (const leafIndex of leafIndexes) { + addAtlasResidencyLeafPages(visibilityLeafPages, pagesByLeaf, leafIndex, pages); + } + } +} + +function addAtlasResidencyLeafPages(visibilityLeafPages, pagesByLeaf, leafIndex, pages) { + if (!Number.isInteger(leafIndex) || leafIndex < 0 || leafIndex >= visibilityLeafPages.length) return; + const current = pagesByLeaf.get(leafIndex) ?? visibilityLeafPages[leafIndex] ?? []; + const next = new Set(current); + for (const pageIndex of pages) { + if (Number.isInteger(pageIndex) && pageIndex >= 0) next.add(pageIndex); + } + const list = [...next].sort((a, b) => a - b); + visibilityLeafPages[leafIndex] = list; + pagesByLeaf.set(leafIndex, list); +} + function renderBundleLeafPageIndexes(html, expectedLeafCount) { const leafPageIndexes = []; for (const match of String(html ?? "").matchAll(/]*)>/g)) { diff --git a/src/quake.css b/src/quake.css index 4c64a1c..3801254 100644 --- a/src/quake.css +++ b/src/quake.css @@ -20,18 +20,22 @@ body { background: #050505; isolation: isolate; } -#quake-app > .polycss-camera, -#quake-ui, -#quake-viewmodel-layer { +#quake-scene, +#quake-menu, +#quake-weapon { position: absolute; inset: 0; } -#quake-app > .polycss-camera { +#quake-scene { z-index: 1; +} +#quake-scene > .polycss-camera { + position: absolute; + inset: 0; outline: none; touch-action: none; } -#quake-ui { +#quake-menu { z-index: 3; pointer-events: none; } @@ -41,6 +45,10 @@ body { #quake-app .polycss-mesh u { image-rendering: pixelated; } +body.quake-debug-fly#quake-app .quake-world-mesh :is(b, i, s, u), +body.quake-debug-fly #quake-app .quake-world-mesh :is(b, i, s, u) { + backface-visibility: visible; +} #quake-app .polycss-mesh s { background-repeat: no-repeat !important; } @@ -88,13 +96,13 @@ body.quake-debug-labels #quake-app .polycss-mesh :is(b, i, s, u)[data-qpid]::aft text-shadow: none; pointer-events: none; } -#quake-viewmodel-layer { +#quake-weapon { z-index: 2; overflow: hidden; pointer-events: none; transform-style: preserve-3d; } -#quake-viewmodel-stage { +.quake-weapon-stage { position: absolute; top: 50%; left: 50%; @@ -103,15 +111,6 @@ body.quake-debug-labels #quake-app .polycss-mesh :is(b, i, s, u)[data-qpid]::aft transform-style: preserve-3d; will-change: transform; } -#quake-viewmodel-raster { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - z-index: 5; - pointer-events: none; - image-rendering: auto; -} #quake-hud { --quake-notify-top: calc(8px + min(45px, calc((100vw - 92px) * 0.1951)) + 12px); --quake-notify-stack-height: 96px; @@ -211,15 +210,65 @@ body.quake-menu-open #quake-hud { opacity: 1; transition-duration: 0ms; } +#quake-impact-particles { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; + contain: layout paint style; +} +.quake-impact-particle { + position: absolute; + left: 50%; + top: 50%; + display: block; + width: 5px; + height: 5px; + border-radius: 50%; + opacity: 0; + pointer-events: none; + transform: translate3d(0, 0, 0) scale(1); + transform-origin: center; + will-change: transform, opacity; +} +.quake-impact-particle-red-a { + background: #8f1111; +} +.quake-impact-particle-red-b { + background: #6f0b0b; +} +.quake-impact-particle-red-c { + background: #a31812; +} +.quake-impact-particle-dust-a, +.quake-impact-particle-dust-b, +.quake-impact-particle-dust-c { + border-radius: 1px; +} +.quake-impact-particle-dust-a { + background: #666666; +} +.quake-impact-particle-dust-b { + background: #666666; +} +.quake-impact-particle-dust-c { + background: #666666; +} body.quake-loading #quake-damage-overlay, body.quake-loading #quake-bonus-overlay, +body.quake-loading #quake-impact-particles, body.quake-menu-open #quake-damage-overlay, -body.quake-menu-open #quake-bonus-overlay { +body.quake-menu-open #quake-bonus-overlay, +body.quake-menu-open #quake-impact-particles, +body.quake-dead #quake-impact-particles, +body.quake-level-complete #quake-impact-particles, +body.quake-menu-unlocked #quake-impact-particles { opacity: 0; transition: none; } body.quake-game-paused .polycss-scene *, -body.quake-game-paused #quake-viewmodel-layer * { +body.quake-game-paused #quake-weapon * { animation-play-state: paused !important; } body.quake-dead #quake-damage-overlay { @@ -363,14 +412,6 @@ body.quake-menu-open .quake-css-logo { outline: 2px solid #f0ad5c; outline-offset: 3px; } -.polycss-pixel-heart { - display: inline-block; - width: 0.75em; - height: 0.75em; - margin-left: -2px; - fill: currentColor; - shape-rendering: crispEdges; -} #quake-loading-overlay { position: absolute; inset: 0; @@ -1851,44 +1892,7 @@ body.quake-menu-open:not(.quake-gameplay-started) .quake-menu-panel { .quake-option-toggle:focus-within .quake-option-checkbox { outline: none; } -.quake-options-list .quake-option-note { - display: block; - max-width: 100%; - line-height: 1; -} -.quake-option-note .quake-bitmap-text { - --quake-bitmap-glyph-size: 16px; - --quake-bitmap-sheet-size: 256px; - --quake-bitmap-space-size: 11px; - gap: 5px var(--quake-bitmap-space-size); - opacity: 0.9; - filter: none; -} -.quake-option-separator { - display: none; -} #quake-debug-stack { - --quake-debug-title-color: rgba(240, 173, 92, 0.62); - --quake-debug-stat-name-color: rgba(216, 137, 63, 0.6); - --quake-debug-stat-value-color: rgba(246, 241, 223, 0.62); - --quake-debug-button-color: rgba(247, 216, 156, 0.68); - --quake-debug-button-border: rgba(191, 116, 44, 0.38); - --quake-debug-button-bg: rgba(78, 34, 18, 0.42); - --quake-debug-button-hover-border: rgba(240, 173, 92, 0.58); - --quake-debug-button-hover-bg: rgba(101, 45, 20, 0.56); - --quake-debug-glyph-filter: brightness(0.72) saturate(0.88) contrast(0.92) drop-shadow(0 1px 0 #000000); - --quake-debug-title-filter: brightness(0.78) saturate(0.9) contrast(0.96) drop-shadow(0 1px 0 #000000); - --quake-debug-legend-filter: brightness(0.76) saturate(0.88) contrast(0.92) drop-shadow(0 1px 0 #000000); - --quake-debug-checkbox-border: rgba(191, 116, 44, 0.4); - --quake-debug-checkbox-bg: rgba(0, 0, 0, 0.64); - --quake-debug-checkbox-checked-border: rgba(240, 173, 92, 0.54); - --quake-debug-checkbox-checked-bg: rgba(78, 34, 18, 0.48); - --quake-debug-checkbox-check: rgba(240, 173, 92, 0.56); - --quake-debug-checkbox-focus: rgba(240, 173, 92, 0.34); - --quake-debug-swatch-world: #006000; - --quake-debug-swatch-special: #008080; - --quake-debug-swatch-movers: #8a7a00; - --quake-debug-swatch-enemies: #8a0000; position: fixed; top: max(18px, env(safe-area-inset-top)); right: max(18px, env(safe-area-inset-right)); @@ -1918,11 +1922,6 @@ body.quake-menu-open:not(.quake-gameplay-started) .quake-menu-panel { --quake-debug-title-color: rgba(240, 173, 92, 0.62); --quake-debug-stat-name-color: rgba(216, 137, 63, 0.6); --quake-debug-stat-value-color: rgba(246, 241, 223, 0.62); - --quake-debug-button-color: rgba(247, 216, 156, 0.68); - --quake-debug-button-border: rgba(191, 116, 44, 0.38); - --quake-debug-button-bg: rgba(78, 34, 18, 0.42); - --quake-debug-button-hover-border: rgba(240, 173, 92, 0.58); - --quake-debug-button-hover-bg: rgba(101, 45, 20, 0.56); --quake-debug-glyph-filter: brightness(0.72) saturate(0.88) contrast(0.92) drop-shadow(0 1px 0 #000000); --quake-debug-title-filter: brightness(0.78) saturate(0.9) contrast(0.96) drop-shadow(0 1px 0 #000000); --quake-debug-legend-filter: brightness(0.76) saturate(0.88) contrast(0.92) drop-shadow(0 1px 0 #000000); @@ -1951,16 +1950,10 @@ body.quake-menu-open:not(.quake-gameplay-started) .quake-menu-panel { color: var(--quake-debug-stat-value-color); transition: border-color 120ms linear, color 120ms linear; } -body.quake-debug-pointer-unlocked #quake-debug-stack, body.quake-debug-pointer-unlocked #quake-debug-card { --quake-debug-title-color: #f0ad5c; --quake-debug-stat-name-color: #d8893f; --quake-debug-stat-value-color: rgba(246, 241, 223, 0.84); - --quake-debug-button-color: #f7d89c; - --quake-debug-button-border: rgba(191, 116, 44, 0.7); - --quake-debug-button-bg: rgba(78, 34, 18, 0.7); - --quake-debug-button-hover-border: rgba(240, 173, 92, 0.96); - --quake-debug-button-hover-bg: rgba(101, 45, 20, 0.86); --quake-debug-glyph-filter: brightness(1.08) contrast(1.05) drop-shadow(0 1px 0 #000000); --quake-debug-title-filter: brightness(1.12) contrast(1.08) drop-shadow(0 1px 0 #000000); --quake-debug-legend-filter: brightness(1.08) contrast(1.05) drop-shadow(0 1px 0 #000000); @@ -2027,32 +2020,6 @@ body.quake-debug-pointer-unlocked #quake-debug-card { #quake-debug-title .quake-bitmap-text { filter: var(--quake-debug-title-filter); } -.quake-debug-record-button { - flex: 0 0 auto; - min-width: 74px; - height: 28px; - padding: 0 10px; - border: 1px solid var(--quake-debug-button-border); - border-radius: 2px; - background: var(--quake-debug-button-bg); - color: var(--quake-debug-button-color); - font: 700 12px/1 ui-monospace, "SFMono-Regular", "Roboto Mono", Menlo, Consolas, monospace; - letter-spacing: 0; - text-align: center; - text-shadow: 0 1px 0 #000000; - cursor: pointer; -} -.quake-debug-record-button:hover, -.quake-debug-record-button:focus-visible { - border-color: var(--quake-debug-button-hover-border); - background: var(--quake-debug-button-hover-bg); - outline: none; -} -.quake-debug-record-button[data-recording="true"] { - border-color: rgba(196, 66, 48, 0.9); - background: rgba(64, 10, 8, 0.82); - color: #ffd39a; -} #quake-debug-stats { display: grid; gap: 7px; @@ -2214,60 +2181,35 @@ body.quake-debug-outlines #quake-debug-outline-legend { .quake-menu-actions button:focus-visible .quake-bitmap-text { filter: none; } -.quake-menu-links { - margin-top: clamp(16px, 2.4vw, 22px); - padding-top: clamp(14px, 2vw, 18px); - border-top: 1px solid rgba(136, 84, 49, 0.58); - font-size: 14px; - line-height: 1.35; -} -.quake-menu-links ul { - display: grid; - gap: 7px; - margin: 0; - padding: 0; - list-style: none; +#quake-weapon .polycss-mesh.viewmodel { + pointer-events: none; + z-index: 4; } -.quake-menu-links li { - position: relative; - padding-left: 16px; +#quake-weapon .polycss-mesh.viewmodel > b, +#quake-weapon .polycss-mesh.viewmodel > i, +#quake-weapon .polycss-mesh.viewmodel > s, +#quake-weapon .polycss-mesh.viewmodel > u, +#quake-weapon .polycss-mesh.viewmodel > .quake-nozzle-group > b, +#quake-weapon .polycss-mesh.viewmodel > .quake-nozzle-group > i, +#quake-weapon .polycss-mesh.viewmodel > .quake-nozzle-group > s, +#quake-weapon .polycss-mesh.viewmodel > .quake-nozzle-group > u { + backface-visibility: visible; + image-rendering: auto; } -.quake-menu-links li::before { - content: ""; +#quake-weapon .polycss-mesh.viewmodel > .quake-nozzle-group { position: absolute; - left: 1px; - top: 0.58em; - width: 5px; - height: 5px; - background: #d8893f; - box-shadow: 0 1px 0 #000000; - transform: translateY(-50%); -} -.quake-menu-links a { - display: inline-flex; - color: #d8893f; - text-decoration: none; - text-shadow: 0 1px 0 #000000; -} -.quake-menu-links a .quake-bitmap-text { - filter: brightness(1.1) contrast(1.08) drop-shadow(0 1px 0 #000000); -} -.quake-menu-links a:hover, -.quake-menu-links a:focus-visible { - color: #f0ad5c; - outline: none; - text-decoration: underline; - text-decoration-thickness: 1px; - text-underline-offset: 3px; -} -.quake-menu-links a:hover .quake-bitmap-text, -.quake-menu-links a:focus-visible .quake-bitmap-text { - filter: brightness(1.18) saturate(1.08) drop-shadow(0 1px 0 #000000); + display: block; + top: 0; + left: 0; + width: 0; + height: 0; + overflow: visible; + transform-style: preserve-3d; + transform-origin: 0 0; + visibility: hidden; } -#quake-viewmodel-layer .polycss-mesh.viewmodel { - pointer-events: none; - z-index: 4; - opacity: 0; +#quake-weapon .polycss-mesh.viewmodel.quake-nozzle-visible > .quake-nozzle-group { + visibility: visible; } .polycss-mesh.shootable.quake-shootable-prewarmed { visibility: hidden; @@ -2286,8 +2228,8 @@ body.quake-debug-outlines #quake-debug-outline-legend { .polycss-mesh.shootable.quake-shootable-corpse { pointer-events: none; } -body.quake-ui-unlocked #quake-crosshair, -body.quake-ui-unlocked #quake-classic-hud { +body.quake-menu-unlocked #quake-crosshair, +body.quake-menu-unlocked #quake-classic-hud { display: none; } body.quake-dead #quake-crosshair, @@ -2296,7 +2238,7 @@ body.quake-dead #quake-classic-hud { } body.quake-level-complete #quake-crosshair, body.quake-level-complete #quake-classic-hud, -body.quake-level-complete #quake-viewmodel-layer { +body.quake-level-complete #quake-weapon { display: none; } body.quake-level-complete .cssquake-logo, @@ -2306,10 +2248,10 @@ body.quake-level-complete .dn-stats-overlay, body.quake-level-complete #quake-debug-stack { display: none !important; } -body.quake-dead #quake-viewmodel-layer { +body.quake-dead #quake-weapon { opacity: 0; } -body.quake-ui-unlocked #quake-viewmodel-layer { +body.quake-menu-unlocked #quake-weapon { opacity: 0; } #quake-mobile-controls { diff --git a/src/runtime/app/debugPanelFlow.ts b/src/runtime/app/debugPanelFlow.ts index 4f3952f..2fb44e0 100644 --- a/src/runtime/app/debugPanelFlow.ts +++ b/src/runtime/app/debugPanelFlow.ts @@ -21,6 +21,7 @@ export interface QuakeDebugPanelFlowOptions { debugShowLabelsOption: HTMLInputElement | null; debugShowMenuOption: HTMLInputElement | null; debugShowOutlinesOption: HTMLInputElement | null; + debugStack: HTMLElement | null; debugShowTexturesOption: HTMLInputElement | null; debugStatElements: ReadonlyMap; hideMainMenu: () => void; @@ -67,6 +68,7 @@ export interface QuakeDebugPanelFlow { syncPanelVisibility: () => void; syncRenderOptions: () => void; toggleMode: () => void; + toggleOutlineTextureMode: () => void; } export function createQuakeDebugPanelFlow(options: QuakeDebugPanelFlowOptions): QuakeDebugPanelFlow { @@ -78,6 +80,8 @@ export function createQuakeDebugPanelFlow(options: QuakeDebugPanelFlowOptions): let showOutlines = options.initialShowOutlines; let showLabels = options.initialShowLabels; let statsTimer: number | null = null; + const debugStackParent = options.debugStack?.parentNode ?? null; + const debugStackNextSibling = options.debugStack?.nextSibling ?? null; function isModeEnabled(): boolean { return mode; @@ -143,6 +147,16 @@ export function createQuakeDebugPanelFlow(options: QuakeDebugPanelFlowOptions): syncRenderOptions(); } + function setOutlineTextureMode(enabled: boolean): void { + hideTextures = enabled; + showOutlines = enabled; + syncRenderOptions(); + } + + function toggleOutlineTextureMode(): void { + setOutlineTextureMode(!(hideTextures && showOutlines)); + } + function syncRenderOptions(): void { const effectiveShowOutlines = showOutlines || hideTextures; if (options.debugShowTexturesOption) options.debugShowTexturesOption.checked = !hideTextures; @@ -160,7 +174,11 @@ export function createQuakeDebugPanelFlow(options: QuakeDebugPanelFlowOptions): } function syncPanelVisibility(): void { - if (!options.debugPanel) return; + syncDebugStackMounted(mode); + if (!options.debugPanel) { + if (!mode) stopStats(); + return; + } options.debugPanel.hidden = !mode; if (mode) { syncPanelStats(); @@ -170,6 +188,17 @@ export function createQuakeDebugPanelFlow(options: QuakeDebugPanelFlowOptions): stopStats(); } + function syncDebugStackMounted(mounted: boolean): void { + if (!options.debugStack || !debugStackParent) return; + if (mounted) { + if (!options.debugStack.isConnected) { + debugStackParent.insertBefore(options.debugStack, debugStackNextSibling); + } + return; + } + options.debugStack.remove(); + } + function startStats(): void { if (statsTimer !== null) return; statsTimer = window.setInterval(syncPanelStats, QUAKE_DEBUG_PANEL_STATS_MS); @@ -239,6 +268,7 @@ export function createQuakeDebugPanelFlow(options: QuakeDebugPanelFlowOptions): syncPanelVisibility, syncRenderOptions, toggleMode, + toggleOutlineTextureMode, }; } diff --git a/src/runtime/app/dom.ts b/src/runtime/app/dom.ts index 4510f78..44fa113 100644 --- a/src/runtime/app/dom.ts +++ b/src/runtime/app/dom.ts @@ -1,7 +1,8 @@ export interface QuakeAppDomElements { app: HTMLElement; - ui: HTMLElement | null; - viewmodelLayer: HTMLElement | null; + scene: HTMLElement | null; + menu: HTMLElement | null; + weapon: HTMLElement | null; mainMenu: HTMLElement | null; mainMenuArt: HTMLElement | null; versionLabel: HTMLElement | null; @@ -31,9 +32,12 @@ export interface QuakeAppDomElements { alwaysRunOption: HTMLInputElement | null; showGunOption: HTMLInputElement | null; dynamicLightingOption: HTMLInputElement | null; + impactParticlesOption: HTMLInputElement | null; + impactParticlesLayer: HTMLElement | null; crosshair: HTMLElement | null; crosshairOption: HTMLButtonElement | null; crosshairOptionValue: HTMLElement | null; + debugStack: HTMLElement | null; debugPanel: HTMLElement | null; debugShowMenuOption: HTMLInputElement | null; debugEnabledOption: HTMLInputElement | null; @@ -45,7 +49,6 @@ export interface QuakeAppDomElements { debugFlyModeOption: HTMLInputElement | null; debugShowOutlinesOption: HTMLInputElement | null; debugShowLabelsOption: HTMLInputElement | null; - debugRecordButton: HTMLButtonElement | null; debugStatElements: Map; loadingOverlay: HTMLElement | null; loadingStatus: HTMLElement | null; @@ -68,8 +71,9 @@ export interface QuakeAppDomElements { export function queryQuakeAppDom(): QuakeAppDomElements { return { app: requiredQuakeElement("quake-app"), - ui: quakeElement("quake-ui"), - viewmodelLayer: quakeElement("quake-viewmodel-layer"), + scene: quakeElement("quake-scene"), + menu: quakeElement("quake-menu"), + weapon: quakeElement("quake-weapon"), mainMenu: quakeElement("quake-main-menu"), mainMenuArt: quakeElement("quake-main-menu-art"), versionLabel: quakeElement("cssquake-version"), @@ -99,9 +103,12 @@ export function queryQuakeAppDom(): QuakeAppDomElements { alwaysRunOption: quakeElement("quake-option-always-run"), showGunOption: quakeElement("quake-option-show-gun"), dynamicLightingOption: quakeElement("quake-option-dynamic-lighting"), + impactParticlesOption: quakeElement("quake-option-impact-particles"), + impactParticlesLayer: quakeElement("quake-impact-particles"), crosshair: quakeElement("quake-crosshair"), crosshairOption: quakeElement("quake-option-crosshair"), crosshairOptionValue: quakeElement("quake-option-crosshair-value"), + debugStack: quakeElement("quake-debug-stack"), debugPanel: quakeElement("quake-debug-panel"), debugShowMenuOption: quakeElement("quake-debug-show-menu"), debugEnabledOption: quakeElement("quake-debug-enabled"), @@ -113,7 +120,6 @@ export function queryQuakeAppDom(): QuakeAppDomElements { debugFlyModeOption: quakeElement("quake-debug-fly-mode"), debugShowOutlinesOption: quakeElement("quake-debug-show-outlines"), debugShowLabelsOption: quakeElement("quake-debug-show-labels"), - debugRecordButton: quakeElement("quake-debug-record"), debugStatElements: new Map( Array.from(document.querySelectorAll("[data-qstat]")) .map((element) => [element.dataset.qstat ?? "", element] as const) diff --git a/src/runtime/app/impactParticleFlow.ts b/src/runtime/app/impactParticleFlow.ts new file mode 100644 index 0000000..7325d74 --- /dev/null +++ b/src/runtime/app/impactParticleFlow.ts @@ -0,0 +1,384 @@ +import type { Vec3 } from "@layoutit/polycss"; + +const QUAKE_IMPACT_PARTICLE_DEFAULT_MAX = 24; +const QUAKE_IMPACT_PARTICLE_MAX_SPAWN = 5; +const QUAKE_IMPACT_PARTICLE_WALL_MAX_SPAWN = 4; +const QUAKE_IMPACT_PARTICLE_BASE_COUNT = 3; +const QUAKE_IMPACT_PARTICLE_WALL_BASE_COUNT = 3; +const QUAKE_IMPACT_PARTICLE_SOURCE_BLOOD_MULTIPLIER = 2; +const QUAKE_IMPACT_PARTICLE_SOURCE_COUNT_SCALE = 0.55; +const QUAKE_IMPACT_PARTICLE_DIRECTION_SPREAD_RADIANS = Math.PI * 0.82; +const QUAKE_IMPACT_PARTICLE_NEAR_DISTANCE = 4; +const QUAKE_IMPACT_PARTICLE_FAR_DISTANCE = 28; +const QUAKE_IMPACT_PARTICLE_NEAR_SCALE = 2; +const QUAKE_IMPACT_PARTICLE_FAR_SCALE = 0.58; +const QUAKE_IMPACT_PARTICLE_DIRECTION_EPSILON = 0.08; +const QUAKE_IMPACT_PARTICLE_CLASS = "quake-impact-particle"; +const QUAKE_IMPACT_PARTICLE_BLOOD_COLORS = [ + "quake-impact-particle-red-a", + "quake-impact-particle-red-b", + "quake-impact-particle-red-c", +] as const; +const QUAKE_IMPACT_PARTICLE_WALL_COLORS = [ + "quake-impact-particle-dust-a", + "quake-impact-particle-dust-b", + "quake-impact-particle-dust-c", +] as const; + +type ImpactParticleKind = "blood" | "wall"; + +export interface QuakeImpactParticleSpawn { + count?: number; + damage?: number; + directionHint?: Vec3; + origin?: Vec3; +} + +export interface QuakeImpactParticleFlow { + clear(): void; + dispose(): void; + setEnabled(enabled: boolean): void; + spawnBlood(input?: QuakeImpactParticleSpawn): void; + spawnWallImpact(input?: QuakeImpactParticleSpawn): void; +} + +export interface QuakeImpactParticleFlowOptions { + canShow(): boolean; + isGameplayPaused(): boolean; + layer: HTMLElement; + maxParticles?: number; + now?: () => number; + viewOrigin?: () => Vec3 | null; + viewRotation?: () => { rotX: number; rotY: number } | null; +} + +interface ImpactParticle { + active: boolean; + dx: number; + dy: number; + durationMs: number; + element: HTMLElement; + rotationDeg: number; + shapeX: number; + shapeY: number; + size: number; + startedAt: number; + x: number; + y: number; +} + +export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOptions): QuakeImpactParticleFlow { + const maxParticles = Math.max(1, Math.floor(options.maxParticles ?? QUAKE_IMPACT_PARTICLE_DEFAULT_MAX)); + const now = options.now ?? (() => performance.now()); + const particles: ImpactParticle[] = []; + let enabled = true; + let frameId: number | null = null; + let nextParticleIndex = 0; + let disposed = false; + + for (let index = 0; index < maxParticles; index++) { + const element = document.createElement("b"); + element.className = + `${QUAKE_IMPACT_PARTICLE_CLASS} ${QUAKE_IMPACT_PARTICLE_BLOOD_COLORS[index % QUAKE_IMPACT_PARTICLE_BLOOD_COLORS.length]}`; + element.setAttribute("aria-hidden", "true"); + options.layer.appendChild(element); + particles.push({ + active: false, + dx: 0, + dy: 0, + durationMs: 0, + element, + rotationDeg: 0, + shapeX: 1, + shapeY: 1, + size: 1, + startedAt: 0, + x: 0, + y: 0, + }); + } + + function setEnabled(nextEnabled: boolean): void { + enabled = nextEnabled; + if (!enabled) clear(); + } + + function spawnBlood(input: QuakeImpactParticleSpawn = {}): void { + spawnParticles("blood", input, resolveBloodParticleCount(input)); + } + + function spawnWallImpact(input: QuakeImpactParticleSpawn = {}): void { + spawnParticles("wall", input, resolveWallParticleCount(input)); + } + + function spawnParticles(kind: ImpactParticleKind, input: QuakeImpactParticleSpawn, count: number): void { + if (!enabled || disposed || options.isGameplayPaused() || !options.canShow()) return; + if (count <= 0) return; + const startedAt = now(); + const distanceScale = particleDistanceScale(input.origin); + const damagePressure = particleDamagePressure(input.damage); + const spreadScale = particleSpreadScale(distanceScale, damagePressure); + const baseAngle = particleScreenAngle(input.directionHint); + for (let index = 0; index < count; index++) { + const particle = nextParticle(); + const angle = particleAngle(baseAngle); + const radius = particleRadius(kind, spreadScale); + const speed = particleSpeed(kind, spreadScale); + const colorClass = particleColorClass(kind); + const shape = particleShape(kind, damagePressure); + particle.active = true; + particle.startedAt = startedAt; + particle.durationMs = particleDuration(kind, damagePressure); + particle.x = Math.cos(angle) * radius; + particle.y = Math.sin(angle) * radius; + particle.dx = Math.cos(angle) * speed; + particle.dy = Math.sin(angle) * speed; + particle.rotationDeg = shape.rotationDeg; + particle.shapeX = shape.x; + particle.shapeY = shape.y; + particle.size = particleSize(kind, distanceScale); + particle.element.className = `${QUAKE_IMPACT_PARTICLE_CLASS} ${colorClass}`; + particle.element.style.transform = particleTransform(particle, 0); + particle.element.style.opacity = "1"; + } + ensureFrame(); + } + + function clear(): void { + for (const particle of particles) { + particle.active = false; + particle.element.style.opacity = "0"; + particle.element.style.transform = "translate3d(0, 0, 0) scale(1, 1)"; + } + cancelFrame(); + } + + function dispose(): void { + disposed = true; + clear(); + for (const particle of particles) particle.element.remove(); + } + + function nextParticle(): ImpactParticle { + const inactive = particles.find((particle) => !particle.active); + if (inactive) return inactive; + const particle = particles[nextParticleIndex]; + nextParticleIndex = (nextParticleIndex + 1) % particles.length; + return particle; + } + + function resolveBloodParticleCount(input: QuakeImpactParticleSpawn): number { + if (input.count !== undefined) return clampParticleCount(Math.floor(input.count)); + if (input.damage !== undefined) return bloodParticleCountForDamage(input.damage); + return clampParticleCount(QUAKE_IMPACT_PARTICLE_BASE_COUNT); + } + + function resolveWallParticleCount(input: QuakeImpactParticleSpawn): number { + if (input.count !== undefined) return clampParticleCount(Math.floor(input.count), QUAKE_IMPACT_PARTICLE_WALL_MAX_SPAWN); + return clampParticleCount(QUAKE_IMPACT_PARTICLE_WALL_BASE_COUNT, QUAKE_IMPACT_PARTICLE_WALL_MAX_SPAWN); + } + + function bloodParticleCountForDamage(damage: number): number { + if (!Number.isFinite(damage) || damage <= 0) return 0; + // QuakeC blood emits damage * 2 particles; compress that into the fixed DOM pool. + const sourceCount = damage * QUAKE_IMPACT_PARTICLE_SOURCE_BLOOD_MULTIPLIER; + const scaledCount = Math.sqrt(sourceCount) * QUAKE_IMPACT_PARTICLE_SOURCE_COUNT_SCALE; + const baseCount = Math.floor(scaledCount); + const roundedCount = baseCount + (Math.random() < scaledCount - baseCount ? 1 : 0); + return clampParticleCount(Math.max(1, roundedCount)); + } + + function clampParticleCount(count: number, maxSpawn = QUAKE_IMPACT_PARTICLE_MAX_SPAWN): number { + if (!Number.isFinite(count)) return 0; + return Math.min(maxSpawn, Math.max(0, count)); + } + + function particleAngle(baseAngle: number | null): number { + if (baseAngle === null) return Math.random() * Math.PI * 2; + return baseAngle + (Math.random() - 0.5) * QUAKE_IMPACT_PARTICLE_DIRECTION_SPREAD_RADIANS; + } + + function particleScreenAngle(directionHint?: Vec3): number | null { + const viewRotation = options.viewRotation?.(); + if (!directionHint || !viewRotation) return null; + const hintLength = Math.hypot(directionHint[0], directionHint[1], directionHint[2]); + if (hintLength <= QUAKE_IMPACT_PARTICLE_DIRECTION_EPSILON) return null; + const direction: Vec3 = [ + directionHint[0] / hintLength, + directionHint[1] / hintLength, + directionHint[2] / hintLength, + ]; + const { right, up } = particleViewAxes(viewRotation.rotX, viewRotation.rotY); + const x = dotVec3(direction, right); + const y = -dotVec3(direction, up); + if (Math.hypot(x, y) <= QUAKE_IMPACT_PARTICLE_DIRECTION_EPSILON) return null; + return Math.atan2(y, x); + } + + function particleSpreadScale(distanceScale: number, damagePressure: number): number { + return (0.82 + distanceScale * 0.18) * (1 + damagePressure * 0.18); + } + + function particleDamagePressure(damage?: number): number { + if (!Number.isFinite(damage)) return 0; + return clamp01(((damage as number) - 4) / 28); + } + + function particleRadius(kind: ImpactParticleKind, spreadScale: number): number { + if (kind === "wall") return (2 + Math.random() * 8) * spreadScale; + return (3 + Math.random() * 12) * spreadScale; + } + + function particleSpeed(kind: ImpactParticleKind, spreadScale: number): number { + if (kind === "wall") return (12 + Math.random() * 22) * spreadScale; + return (20 + Math.random() * 28) * spreadScale; + } + + function particleDuration(kind: ImpactParticleKind, damagePressure: number): number { + if (kind === "wall") return 120 + Math.random() * 100; + return 170 + damagePressure * 35 + Math.random() * (80 + damagePressure * 35); + } + + function particleColorClass(kind: ImpactParticleKind): string { + const colors = kind === "wall" ? QUAKE_IMPACT_PARTICLE_WALL_COLORS : QUAKE_IMPACT_PARTICLE_BLOOD_COLORS; + return colors[Math.floor(Math.random() * colors.length) % colors.length]; + } + + function particleSize(kind: ImpactParticleKind, distanceScale: number): number { + const styleScale = kind === "wall" ? 1.12 : 1; + const variance = kind === "wall" ? 0.28 : 0.35; + const nearBoost = kind === "wall" ? 1 + Math.max(0, distanceScale - 1) * 0.35 : 1; + return distanceScale * styleScale * nearBoost * (1 + Math.random() * variance); + } + + function particleShape(kind: ImpactParticleKind, damagePressure: number): { rotationDeg: number; x: number; y: number } { + const roundChance = kind === "wall" ? 0.74 : 0.62; + if (Math.random() <= roundChance) return { rotationDeg: 0, x: 1, y: 1 }; + const stretch = 1.08 + Math.random() * (0.16 + damagePressure * 0.12); + return { + rotationDeg: Math.random() * 360, + x: stretch, + y: Math.max(0.74, 1 / stretch), + }; + } + + function ensureFrame(): void { + if (frameId !== null) return; + frameId = requestQuakeAnimationFrame(tick); + } + + function cancelFrame(): void { + if (frameId === null) return; + cancelQuakeAnimationFrame(frameId); + frameId = null; + } + + function particleDistanceScale(origin?: Vec3): number { + const viewOrigin = options.viewOrigin?.(); + if (!origin || !viewOrigin) return 1; + const distance = Math.hypot( + origin[0] - viewOrigin[0], + origin[1] - viewOrigin[1], + origin[2] - viewOrigin[2], + ); + const t = clamp01( + (distance - QUAKE_IMPACT_PARTICLE_NEAR_DISTANCE) / + (QUAKE_IMPACT_PARTICLE_FAR_DISTANCE - QUAKE_IMPACT_PARTICLE_NEAR_DISTANCE), + ); + return QUAKE_IMPACT_PARTICLE_NEAR_SCALE + + (QUAKE_IMPACT_PARTICLE_FAR_SCALE - QUAKE_IMPACT_PARTICLE_NEAR_SCALE) * t; + } + + function tick(at: number): void { + frameId = null; + if (disposed || !enabled || options.isGameplayPaused() || !options.canShow()) { + clear(); + return; + } + let activeCount = 0; + for (const particle of particles) { + if (!particle.active) continue; + const t = Math.min(1, Math.max(0, (at - particle.startedAt) / particle.durationMs)); + if (t >= 1) { + particle.active = false; + particle.element.style.opacity = "0"; + particle.element.style.transform = particleTransform(particle, 1); + continue; + } + activeCount++; + particle.element.style.transform = particleTransform(particle, t); + particle.element.style.opacity = String(1 - t); + } + if (activeCount > 0) ensureFrame(); + } + + return { + clear, + dispose, + setEnabled, + spawnBlood, + spawnWallImpact, + }; +} + +function particleTransform(particle: ImpactParticle, t: number): string { + const x = particle.x + particle.dx * t; + const y = particle.y + particle.dy * t; + const scale = particle.size * (1 - t * 0.35); + const scaleX = scale * particle.shapeX; + const scaleY = scale * particle.shapeY; + return `translate3d(${x.toFixed(3)}px, ${y.toFixed(3)}px, 0) ` + + `rotate(${particle.rotationDeg.toFixed(3)}deg) scale(${scaleX.toFixed(3)}, ${scaleY.toFixed(3)})`; +} + +function particleViewAxes(rotX: number, rotY: number): { right: Vec3; up: Vec3 } { + const rx = (rotX * Math.PI) / 180; + const ry = (rotY * Math.PI) / 180; + const forward: Vec3 = [ + -Math.sin(rx) * Math.cos(ry), + -Math.sin(rx) * Math.sin(ry), + -Math.cos(rx), + ]; + const right = normalizeVec3([-Math.sin(ry), Math.cos(ry), 0]); + return { + right, + up: normalizeVec3(crossVec3(right, forward)), + }; +} + +function crossVec3(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function dotVec3(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +function normalizeVec3(value: Vec3): Vec3 { + const length = Math.hypot(value[0], value[1], value[2]); + if (length <= QUAKE_IMPACT_PARTICLE_DIRECTION_EPSILON) return [0, 0, 0]; + return [value[0] / length, value[1] / length, value[2] / length]; +} + +function clamp01(value: number): number { + if (value <= 0) return 0; + if (value >= 1) return 1; + return value; +} + +function requestQuakeAnimationFrame(callback: FrameRequestCallback): number { + if (typeof requestAnimationFrame === "function") return requestAnimationFrame(callback); + return window.setTimeout(() => callback(performance.now()), 16); +} + +function cancelQuakeAnimationFrame(frameId: number): void { + if (typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(frameId); + return; + } + window.clearTimeout(frameId); +} diff --git a/src/runtime/app/input.ts b/src/runtime/app/input.ts index 32070a0..199443a 100644 --- a/src/runtime/app/input.ts +++ b/src/runtime/app/input.ts @@ -31,8 +31,8 @@ export interface QuakeAppInputControllerOptions { showMainMenu(): void; syncViewmodelTransform(): void; toggleAudioMuted(): void; - toggleDebugRecording(): void; toggleDebugMode(): void; + toggleOutlineTextureMode(): void; } export function createQuakeAppInputController(options: QuakeAppInputControllerOptions): QuakeAppInputController { @@ -49,7 +49,7 @@ export function createQuakeAppInputController(options: QuakeAppInputControllerOp if (event.code === "KeyO" && !options.isEditableTarget(event.target)) { event.preventDefault(); event.stopPropagation(); - if (!event.repeat) options.toggleDebugRecording(); + if (!event.repeat) options.toggleOutlineTextureMode(); return; } if (options.isLoading()) { diff --git a/src/runtime/app/optionsFlow.ts b/src/runtime/app/optionsFlow.ts index e728493..b93cc75 100644 --- a/src/runtime/app/optionsFlow.ts +++ b/src/runtime/app/optionsFlow.ts @@ -24,12 +24,14 @@ export interface QuakeOptionsFlowOptions { disableEnemiesOption: HTMLInputElement | null; disableSoundOption: HTMLInputElement | null; dynamicLightingOption: HTMLInputElement | null; + impactParticlesOption: HTMLInputElement | null; invertMouseOption: HTMLInputElement | null; showGunOption: HTMLInputElement | null; audioMuted(): boolean; damageDisabled(): boolean; dynamicLightingEnabled(): boolean; enemiesDisabled(): boolean; + impactParticlesEnabled(): boolean; invertMouse(): boolean; alwaysRun(): boolean; showGun(): boolean; @@ -40,6 +42,7 @@ export interface QuakeOptionsFlowOptions { setDamageDisabled(disabled: boolean): void; setDynamicLighting(enabled: boolean): void; setEnemiesDisabled(disabled: boolean): void; + setImpactParticles(enabled: boolean): void; setInvertMouse(invert: boolean): void; setShowGun(enabled: boolean): void; setStaticLightingClass(enabled: boolean): void; @@ -55,6 +58,7 @@ export interface QuakeOptionsFlow { syncAudioToggle(): void; syncControls(): void; syncDynamicLightingOption(): void; + syncImpactParticlesOption(): void; } export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeOptionsFlow { @@ -70,6 +74,10 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO options.setStaticLightingClass(!enabled); } + function syncImpactParticlesOption(): void { + if (options.impactParticlesOption) options.impactParticlesOption.checked = options.impactParticlesEnabled(); + } + function setCrosshairOption(value: QuakeCrosshairOption): void { crosshairOption = value; const definition = quakeCrosshairDefinition(value); @@ -78,7 +86,6 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO options.crosshair.hidden = definition.value === "off"; } if (options.crosshairOption) { - options.crosshairOption.dataset.quakeCrosshair = definition.value; options.crosshairOption.setAttribute("aria-label", `Crosshair ${definition.label}`); } if (options.crosshairOptionValue) { @@ -104,6 +111,7 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO options.syncDebugControls(); options.syncDebugFlyMode(); syncDynamicLightingOption(); + syncImpactParticlesOption(); if (options.invertMouseOption) options.invertMouseOption.checked = options.invertMouse(); if (options.alwaysRunOption) options.alwaysRunOption.checked = options.alwaysRun(); if (options.showGunOption) options.showGunOption.checked = options.showGun(); @@ -127,6 +135,10 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO options.setDynamicLighting((event.currentTarget as HTMLInputElement).checked); } + function handleImpactParticlesOptionChange(event: Event): void { + options.setImpactParticles((event.currentTarget as HTMLInputElement).checked); + } + function handleAlwaysRunOptionChange(event: Event): void { options.setAlwaysRun((event.currentTarget as HTMLInputElement).checked); } @@ -155,6 +167,7 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO options.disableEnemiesOption?.addEventListener("change", handleDisableEnemiesOptionChange); options.disableDamageOption?.addEventListener("change", handleDisableDamageOptionChange); options.dynamicLightingOption?.addEventListener("change", handleDynamicLightingOptionChange); + options.impactParticlesOption?.addEventListener("change", handleImpactParticlesOptionChange); options.alwaysRunOption?.addEventListener("change", handleAlwaysRunOptionChange); options.showGunOption?.addEventListener("change", handleShowGunOptionChange); options.crosshairOption?.addEventListener("click", handleCrosshairOptionClick); @@ -167,6 +180,7 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO options.disableEnemiesOption?.removeEventListener("change", handleDisableEnemiesOptionChange); options.disableDamageOption?.removeEventListener("change", handleDisableDamageOptionChange); options.dynamicLightingOption?.removeEventListener("change", handleDynamicLightingOptionChange); + options.impactParticlesOption?.removeEventListener("change", handleImpactParticlesOptionChange); options.alwaysRunOption?.removeEventListener("change", handleAlwaysRunOptionChange); options.showGunOption?.removeEventListener("change", handleShowGunOptionChange); options.crosshairOption?.removeEventListener("click", handleCrosshairOptionClick); @@ -182,6 +196,7 @@ export function createQuakeOptionsFlow(options: QuakeOptionsFlowOptions): QuakeO syncAudioToggle, syncControls, syncDynamicLightingOption, + syncImpactParticlesOption, }; } diff --git a/src/runtime/app/playerLifecycleFlow.ts b/src/runtime/app/playerLifecycleFlow.ts index eb98c96..1e7851d 100644 --- a/src/runtime/app/playerLifecycleFlow.ts +++ b/src/runtime/app/playerLifecycleFlow.ts @@ -266,10 +266,10 @@ export function createQuakePlayerLifecycleFlow( } async function startNewGame(): Promise { - const startMap = options.startMap(); - if (!options.currentResult() || options.currentMapName() !== startMap) { - await options.loadMap(startMap, { - loadingStatus: `World ${startMap}.bsp`, + const mapName = options.currentResult() ? options.currentMapName() : options.startMap(); + if (!options.currentResult()) { + await options.loadMap(mapName, { + loadingStatus: `World ${mapName}.bsp`, preserveLoadingConsole: true, urlMode: "push", }); diff --git a/src/runtime/debug/recording.ts b/src/runtime/debug/recording.ts index 66f83ed..6a588ec 100644 --- a/src/runtime/debug/recording.ts +++ b/src/runtime/debug/recording.ts @@ -189,7 +189,6 @@ export interface QuakeDebugRecording { export interface QuakeDebugRecorderOptions { appVersion: string; - button: HTMLButtonElement | null; statusElement: HTMLElement | null; currentMapName: () => string; entityManifest?: () => unknown; @@ -392,35 +391,9 @@ export function createQuakeDebugRecorder(options: QuakeDebugRecorderOptions): Qu stop("dispose"); unregisterTraceMarkSink?.(); unregisterTraceMarkSink = null; - options.button?.removeEventListener("click", handleClick); - } - - function handleClick(): void { - if (recording) { - stop(); - } else { - start(); - } } function updatePresentation(finished?: QuakeDebugRecording): void { - if (options.button) { - options.button.textContent = recording ? "STOP" : "RECORD"; - options.button.dataset.recording = recording ? "true" : "false"; - options.button.setAttribute("aria-pressed", recording ? "true" : "false"); - options.button.setAttribute("aria-label", recording ? "Stop debug recording" : "Record debug data"); - if (recording) { - delete options.button.dataset.lastSamples; - delete options.button.dataset.lastEvents; - delete options.button.dataset.lastCullingEntries; - } else if (finished) { - options.button.dataset.lastSamples = String(finished.samples.length); - options.button.dataset.lastEvents = String(finished.events.length); - options.button.dataset.lastCullingEntries = String( - finished.samples[finished.samples.length - 1]?.snapshot.shootableCulling.entries.length ?? 0, - ); - } - } if (!options.statusElement) return; if (recording) { const duration = Math.max(0, performance.now() - startedAt) / 1000; @@ -435,7 +408,6 @@ export function createQuakeDebugRecorder(options: QuakeDebugRecorderOptions): Qu options.statusElement.textContent = "-"; } - options.button?.addEventListener("click", handleClick); updatePresentation(); return { diff --git a/src/runtime/debug/statsPanel.ts b/src/runtime/debug/statsPanel.ts index f745843..1fb8e15 100644 --- a/src/runtime/debug/statsPanel.ts +++ b/src/runtime/debug/statsPanel.ts @@ -1,18 +1,14 @@ interface QuakeStatsPanel { value: HTMLElement; - canvas: HTMLCanvasElement; - context: CanvasRenderingContext2D; + bars: HTMLElement[]; history: number[]; max: number; label: string; - fg: string; } const FPS_SAMPLE_MS = 1000; const MS_SAMPLE_MS = 500; const STATS_GRAPH_COLUMNS = 40; -const STATS_GRAPH_COLUMN_WIDTH = 2; -const STATS_GRAPH_WIDTH = STATS_GRAPH_COLUMNS * STATS_GRAPH_COLUMN_WIDTH; const STATS_GRAPH_HEIGHT = 30; const STATS_OVERLAY_BACKGROUND = "#050302"; const STATS_GRAPH_BACKGROUND = "#050302"; @@ -102,22 +98,25 @@ function createStatsPanel(label: string, fg: string, bg: string, max: number): Q const graph = document.createElement("div"); graph.style.position = "relative"; + graph.style.display = "grid"; + graph.style.gridTemplateColumns = `repeat(${STATS_GRAPH_COLUMNS}, 1fr)`; + graph.style.alignItems = "end"; + graph.style.gap = "0"; graph.style.height = `${STATS_GRAPH_HEIGHT}px`; graph.style.background = STATS_GRAPH_BACKGROUND; graph.style.overflow = "hidden"; - const canvas = document.createElement("canvas"); - canvas.width = STATS_GRAPH_WIDTH; - canvas.height = STATS_GRAPH_HEIGHT; - canvas.style.display = "block"; - canvas.style.width = "100%"; - canvas.style.height = `${STATS_GRAPH_HEIGHT}px`; - canvas.style.imageRendering = "pixelated"; - const context = canvas.getContext("2d"); - if (!context) throw new Error("Stats panel canvas context unavailable."); - graph.appendChild(canvas); + const bars = Array.from({ length: STATS_GRAPH_COLUMNS }, () => { + const bar = document.createElement("span"); + bar.style.display = "block"; + bar.style.width = "100%"; + bar.style.height = "0"; + bar.style.background = fg; + graph.appendChild(bar); + return bar; + }); element.append(value, graph); - const panel = { element, value, canvas, context, history: [], max, label, fg }; + const panel = { element, value, bars, history: [], max, label }; drawStatsPanelGraph(panel); return panel; } @@ -131,17 +130,10 @@ function updateStatsPanel(panel: QuakeStatsPanel, value: number): void { } function drawStatsPanelGraph(panel: QuakeStatsPanel): void { - const { context } = panel; - context.clearRect(0, 0, STATS_GRAPH_WIDTH, STATS_GRAPH_HEIGHT); - context.fillStyle = STATS_GRAPH_BACKGROUND; - context.fillRect(0, 0, STATS_GRAPH_WIDTH, STATS_GRAPH_HEIGHT); - context.fillStyle = panel.fg; const offset = STATS_GRAPH_COLUMNS - panel.history.length; - for (let index = 0; index < panel.history.length; index++) { - const value = panel.history[index] ?? 0; + for (let index = 0; index < panel.bars.length; index++) { + const value = index >= offset ? panel.history[index - offset] ?? 0 : 0; const height = Math.round((value / panel.max) * STATS_GRAPH_HEIGHT); - const x = (offset + index) * STATS_GRAPH_COLUMN_WIDTH; - const y = STATS_GRAPH_HEIGHT - height; - context.fillRect(x, y, STATS_GRAPH_COLUMN_WIDTH, height); + panel.bars[index].style.height = `${height}px`; } } diff --git a/src/runtime/multiplayer/loopback.ts b/src/runtime/multiplayer/loopback.ts index ed6521a..0674128 100644 --- a/src/runtime/multiplayer/loopback.ts +++ b/src/runtime/multiplayer/loopback.ts @@ -610,7 +610,7 @@ export function createQuakeLoopbackMultiplayerSession( appliedHazardDamage = applyLocalRoomDamage({ damage: hazard.damage, damageSource: hazard.kind, - eventId: `liquid-${hazard.kind}-${hazard.damagedAt}`, + eventId: `hazard-${hazard.kind}-${hazard.damagedAt}`, }) || appliedHazardDamage; } return result.advancedTicks > 0 || appliedHazardDamage; diff --git a/src/runtime/multiplayer/partyRoom.ts b/src/runtime/multiplayer/partyRoom.ts index 6b288e0..b550cd2 100644 --- a/src/runtime/multiplayer/partyRoom.ts +++ b/src/runtime/multiplayer/partyRoom.ts @@ -685,7 +685,7 @@ export default class CssQuakeMultiplayerRoom implements Party.Server { victimPlayerId: playerId, damage: hazard.damage, source: hazard.kind, - eventId: `liquid-${hazard.kind}-${playerId}-${hazard.damagedAt}`, + eventId: `hazard-${hazard.kind}-${playerId}-${hazard.damagedAt}`, }); } advanced = true; diff --git a/src/runtime/multiplayer/simulation.ts b/src/runtime/multiplayer/simulation.ts index c088c28..76e35df 100644 --- a/src/runtime/multiplayer/simulation.ts +++ b/src/runtime/multiplayer/simulation.ts @@ -5,10 +5,12 @@ import { QUAKE_PLAYER_VIEW_Z, } from "../constants"; import { + QUAKE_CONTENTS_WATER, QUAKE_CONTENTS_LAVA, QUAKE_CONTENTS_SLIME, quakePlayerWaterLevel, } from "../hazards"; +import { quakePlayerFallDamageFromVelocityZ } from "../playerPhysics"; import { quakeMultiplayerAdvancePlayerWithInputResult } from "./movement"; import type { QuakeMultiplayerAuthoritativePlayerState, @@ -36,6 +38,7 @@ export interface QuakeMultiplayerRoomPlayerSimulationState { playerId: string; grounded: boolean; floorZ?: number; + fallVelocityZ?: number; lastAcceptedInput?: QuakeMultiplayerLocalInputIntent; lastAcceptedInputSequence: number; lastSimulatedAt: number; @@ -48,7 +51,7 @@ export interface QuakeMultiplayerRoomPlayerSimulationState { export interface QuakeMultiplayerRoomHazardDamage { damagedAt: number; damage: number; - kind: "drown" | "lava" | "slime"; + kind: "drown" | "fall" | "lava" | "slime"; waterLevel: number; } @@ -175,8 +178,11 @@ export function advanceQuakeMultiplayerRoomPlayerSimulation( let lastAcceptedInputSequence = nextState.lastAcceptedInputSequence; let grounded = nextState.grounded; let floorZ = nextState.floorZ; + let fallVelocityZ = nextState.fallVelocityZ; if (tickInput) { + const wasGrounded = nextState.grounded; + const incomingVelocityZ = nextPlayer.velocity[2]; const advanced = quakeMultiplayerAdvancePlayerWithInputResult(nextPlayer, tickInput, { now: simulatedAt, maxDt: tickMs / 1000, @@ -188,6 +194,26 @@ export function advanceQuakeMultiplayerRoomPlayerSimulation( nextPlayer = advanced.player; grounded = advanced.grounded ?? grounded; floorZ = advanced.groundZ ?? floorZ; + if (!wasGrounded && grounded) { + const landingVelocityZ = incomingVelocityZ < 0 ? incomingVelocityZ : (fallVelocityZ ?? 0); + const damage = quakePlayerFallDamageFromVelocityZ(landingVelocityZ); + if ( + damage > 0 && + !quakeMultiplayerFallDamageBlockedByWater(nextPlayer, options.collisionWorld, options.playerEyeHeight) + ) { + hazardDamages.push({ + damagedAt: simulatedAt, + damage, + kind: "fall", + waterLevel: 0, + }); + } + fallVelocityZ = undefined; + } else if (grounded) { + fallVelocityZ = undefined; + } else { + fallVelocityZ = nextPlayer.velocity[2] < 0 ? nextPlayer.velocity[2] : undefined; + } if (selected.input) { lastAcceptedInput = selected.input; lastAcceptedInputSequence = selected.input.inputSequence; @@ -212,9 +238,11 @@ export function advanceQuakeMultiplayerRoomPlayerSimulation( simulatedAt, }); + const { fallVelocityZ: _previousFallVelocityZ, ...stateWithoutFallVelocity } = nextState; nextState = { - ...nextState, + ...stateWithoutFallVelocity, ...(floorZ !== undefined ? { floorZ } : {}), + ...(fallVelocityZ !== undefined ? { fallVelocityZ } : {}), grounded, lastAcceptedInput, lastAcceptedInputSequence, @@ -451,6 +479,17 @@ function quakeMultiplayerStateWithoutLiquidDamageTimer( return next; } +function quakeMultiplayerFallDamageBlockedByWater( + player: QuakeMultiplayerAuthoritativePlayerState, + collisionWorld: Pick | null | undefined, + playerEyeHeight: number | undefined, +): boolean { + const contentsAt = collisionWorld?.contentsAt; + if (!contentsAt) return false; + const eyeHeight = normalizePositiveNumber(playerEyeHeight, QUAKE_PLAYER_VIEW_Z - QUAKE_PLAYER_MINS_Z); + return contentsAt(quakeMultiplayerLiquidContentsPoint(player.origin, eyeHeight)) === QUAKE_CONTENTS_WATER; +} + function quakeMultiplayerLiquidContentsPoint( origin: QuakeMultiplayerVec3, playerEyeHeight: number, diff --git a/src/runtime/player.ts b/src/runtime/player.ts index 9bad3c7..df899cc 100644 --- a/src/runtime/player.ts +++ b/src/runtime/player.ts @@ -10,7 +10,7 @@ import { QUAKE_PLAYER_MINS_Z, STEP_HEIGHT, } from "./constants"; -import type { QuakeHazardDamage } from "./hazards"; +import { QUAKE_CONTENTS_WATER, type QuakeHazardDamage } from "./hazards"; import { markQuakeTrace } from "./debug/traceMarks"; import { applyQuakeDamageToInventory, @@ -30,6 +30,7 @@ import { QUAKE_PMOVE_FORWARD_SPEED, QUAKE_PMOVE_SIDE_SPEED, QUAKE_PMOVE_SPEED_KEY_MULTIPLIER, + quakePlayerFallDamageFromVelocityZ, updateQuakePlayerPhysics, type QuakePlayerMoveCommand, } from "./playerPhysics"; @@ -305,6 +306,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption let fallingFrame: number | null = null; let fallingTime = 0; let fallingVelocity = 0; + let fallDamageVelocityZ = 0; let pushFrame: number | null = null; let pushTime = 0; let pushVelocity: Vec3 = [0, 0, 0]; @@ -438,6 +440,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption clearMoveInput(); stopMoveFrame(); moveVelocity = [0, 0, 0]; + fallDamageVelocityZ = 0; stopFalling(); stopPush(); stopDeathToss(); @@ -922,6 +925,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption const dt = Math.min(QUAKE_PMOVE_DT_CLAMP, moveTime ? (frameNow - moveTime) / 1000 : 0.0167); moveTime = frameNow; + const wasGroundedAtTickStart = currentGrounded; const origin = options.controls.getOrigin(); const footZ = origin[2] - currentEyeHeight; const snapGroundZ = !currentGrounded && moveVelocity[2] <= 0 @@ -991,13 +995,17 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption moveVelocity[0] = actualDeltaX / dt; moveVelocity[1] = actualDeltaY / dt; } + const landingVelocityZ = !wasGroundedAtTickStart && resolved.grounded + ? (moveVelocity[2] < 0 ? moveVelocity[2] : fallDamageVelocityZ) + : 0; if (resolved.grounded) { moveVelocity[2] = 0; } else if (moveVelocity[2] > 0 && actualDeltaZ < intendedDeltaZ * 0.25) { moveVelocity[2] = 0; } + if (!resolved.grounded) rememberFallDamageVelocity(moveVelocity[2]); - applyCollisionResult(resolved, origin, false); + applyCollisionResult(resolved, origin, false, landingVelocityZ); if (moveFrame === null && hasMoveMotion()) scheduleMoveFrame(); }; @@ -1060,6 +1068,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption resolved: { origin: [number, number, number]; groundZ: number; grounded: boolean; touches?: QuakeTouchedTrigger[] }, previousOrigin: [number, number, number], jumpEnabled = false, + landingVelocityZ = 0, ): void { const moved = distanceSq3(previousOrigin, resolved.origin) > COLLISION_EPSILON; const groundDelta = resolved.groundZ - currentGroundZ; @@ -1087,6 +1096,10 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption } if (resolved.grounded) lastSafeOrigin = resolved.origin; + if (resolved.grounded) { + if (applyFallDamage(landingVelocityZ, resolved.origin)) return; + fallDamageVelocityZ = 0; + } lastGroundEntityIndex = resolved.touches?.find((touch) => touch.contact === "floor")?.entityIndex ?? null; for (const touch of resolved.touches ?? []) { options.activateSolidTouch(touch); @@ -1157,6 +1170,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption clearMoveInput(); stopMoveFrame(); moveVelocity = [0, 0, 0]; + fallDamageVelocityZ = 0; stopFalling(); stopPush(); stopDeathToss(); @@ -1355,6 +1369,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption options.syncCrosshairTarget(); if (landed) { + if (applyFallDamage(-fallingVelocity, nextOrigin)) return; stopFalling(); return; } @@ -1379,6 +1394,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption pushVelocity[2] -= options.gravity * dt; const origin = options.controls.getOrigin(); + const wasGroundedAtTickStart = currentGrounded; const target: [number, number, number] = [ origin[0] + pushVelocity[0] * dt, origin[1] + pushVelocity[1] * dt, @@ -1392,6 +1408,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption const actualDelta = subtractVec3(resolved.origin, origin); const intendedDelta = subtractVec3(target, origin); const grounded = resolved.grounded; + const landingVelocityZ = !wasGroundedAtTickStart && grounded ? pushVelocity[2] : 0; if (grounded && pushVelocity[2] < 0) pushVelocity[2] = 0; if (!grounded && pushVelocity[2] > 0 && actualDelta[2] < intendedDelta[2] * 0.25) { @@ -1403,6 +1420,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption pushVelocity[1] *= damping; setOrigin(resolved.origin, resolved.groundZ, false, grounded, "move"); + if (grounded && applyFallDamage(landingVelocityZ, resolved.origin)) return; lastGroundEntityIndex = resolved.touches?.find((touch) => touch.contact === "floor")?.entityIndex ?? null; for (const touch of resolved.touches ?? []) { options.activateSolidTouch(touch); @@ -1460,6 +1478,32 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption } }; + function rememberFallDamageVelocity(velocityZ: number): void { + fallDamageVelocityZ = Number.isFinite(velocityZ) && velocityZ < 0 ? velocityZ : 0; + } + + function applyFallDamage(velocityZ: number, origin: [number, number, number]): boolean { + const damage = quakePlayerFallDamageFromVelocityZ(velocityZ); + if (damage <= 0) return false; + if (fallDamageBlockedByWater(origin)) { + markQuakeTrace("player-fall-damage-blocked", { damage, velocityZ, reason: "water" }); + return false; + } + markQuakeTrace("player-fall-damage", { damage, velocityZ }); + const previousVelocityZ = moveVelocity[2]; + if (velocityZ < 0) moveVelocity[2] = velocityZ; + const died = applyDamage(damage); + if (!died) moveVelocity[2] = previousVelocityZ; + return died; + } + + function fallDamageBlockedByWater(origin: [number, number, number]): boolean { + const contentsAt = options.getCollisionWorld()?.contentsAt; + if (!contentsAt) return false; + const footZ = origin[2] - currentEyeHeight; + return contentsAt([origin[0], origin[1], footZ + QUAKE_COLLISION_UNIT_SCALE]) === QUAKE_CONTENTS_WATER; + } + const carryWithMover = (delta: Vec3, entityIndex: number): void => { if (dead) return; const origin = options.controls.getOrigin(); diff --git a/src/runtime/playerPhysics.ts b/src/runtime/playerPhysics.ts index b099431..b2ab508 100644 --- a/src/runtime/playerPhysics.ts +++ b/src/runtime/playerPhysics.ts @@ -11,6 +11,8 @@ export const QUAKE_PMOVE_SPEED_KEY_MULTIPLIER = 2; export const QUAKE_PMOVE_EDGE_DISTANCE = 16 * QUAKE_COLLISION_UNIT_SCALE; export const QUAKE_PMOVE_EDGE_DROP = 34 * QUAKE_COLLISION_UNIT_SCALE; export const QUAKE_PMOVE_EDGE_FRICTION = 2; +export const QUAKE_PLAYER_FALL_LAND_SOUND_VELOCITY = 300 * QUAKE_COLLISION_UNIT_SCALE; +export const QUAKE_PLAYER_FALL_DAMAGE_VELOCITY = 650 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_PMOVE_ACCELERATE = 10; const QUAKE_PMOVE_AIR_ACCELERATE = 10; @@ -67,6 +69,11 @@ export function updateQuakePlayerPhysics( return grounded; } +export function quakePlayerFallDamageFromVelocityZ(velocityZ: number): number { + // QuakeC PlayerPostThink only damages the hard land2 branch; the 300-650 band is sound-only. + return Number.isFinite(velocityZ) && velocityZ < -QUAKE_PLAYER_FALL_DAMAGE_VELOCITY ? 5 : 0; +} + function applyQuakeFriction(velocity: Vec3, dt: number, frictionScale: number): void { const speed = Math.hypot(velocity[0], velocity[1]); if (speed <= COLLISION_EPSILON) return; diff --git a/src/runtime/pointerTrace.ts b/src/runtime/pointerTrace.ts index aaad247..9f699ab 100644 --- a/src/runtime/pointerTrace.ts +++ b/src/runtime/pointerTrace.ts @@ -38,9 +38,7 @@ export function createQuakePointerTracer(options: QuakePointerTracerOptions): Qu traceWindow.__cssQuakePointerTraceClear = () => { serial = 0; traceWindow.__cssQuakePointerTrace = []; - syncTraceDom([]); }; - syncTraceDom(traceWindow.__cssQuakePointerTrace); return traceWindow; } @@ -62,7 +60,6 @@ export function createQuakePointerTracer(options: QuakePointerTracerOptions): Qu traceEntries.splice(0, traceEntries.length - QUAKE_POINTER_TRACE_LIMIT); } traceWindow.__cssQuakePointerTrace = traceEntries; - syncTraceDom(traceEntries); if (options.logToConsole()) { console.debug(`cssquake:pointer ${JSON.stringify(entry)}`); } @@ -71,18 +68,6 @@ export function createQuakePointerTracer(options: QuakePointerTracerOptions): Qu return { syncAccessors, trace }; } -function syncTraceDom(trace: readonly QuakePointerTraceEntry[]): void { - let element = document.getElementById("quake-pointer-trace-dump") as HTMLScriptElement | null; - if (!element) { - element = document.createElement("script"); - element.id = "quake-pointer-trace-dump"; - element.type = "application/json"; - document.body.appendChild(element); - } - element.dataset.count = String(trace.length); - element.textContent = JSON.stringify(trace); -} - export function quakePointerEventTargetLabel(target: EventTarget | null): string | null { if (!target) return null; if (target === window) return "window"; diff --git a/src/runtime/renderBundleMesh.ts b/src/runtime/renderBundleMesh.ts index 79f9140..9c171d5 100644 --- a/src/runtime/renderBundleMesh.ts +++ b/src/runtime/renderBundleMesh.ts @@ -6,7 +6,7 @@ import { } from "@layoutit/polycss"; import type { QuakePreparedRenderBundle, QuakeRenderBundleLeafFrameStyle } from "../types/quake"; -import { isQuakeDebugDomMetadataEnabled, markQuakeTrace } from "./debug/traceMarks"; +import { markQuakeTrace } from "./debug/traceMarks"; export interface QuakeRenderBundleFrameSetFrame { name: string; @@ -79,6 +79,7 @@ let renderBundleDebugLabelsEnabled = false; const renderBundleDebugLeafBackgrounds = new WeakMap(); const renderBundleDebugLeavesByElement = new WeakMap>(); const renderBundleDebugOutlineLeavesByElement = new WeakMap>(); +const renderBundleDebugHiddenTextureLeaves = new WeakSet(); const QUAKE_MOTION_MATERIAL_ACTIVE_CLASS = "quake-motion-material-active"; const QUAKE_MOTION_MATERIAL_TARGET_CLASS = "quake-motion-material-target"; const QUAKE_MOTION_MATERIAL_STYLE_ID = "quake-motion-material-style"; @@ -266,7 +267,6 @@ export function mountQuakeRenderBundleFrameSetMesh( } const handle = mountQuakeRenderBundleMesh(sceneElement, frameSet.renderBundle) as QuakeRenderBundleFrameSetHandle; let currentFrameIndex = 0; - syncQuakeRenderBundleFrameSetMetadata(handle.element, currentFrameIndex); const frameSetLeaves = handle.element.querySelectorAll("b,i,s,u"); const styleOptimization = quakeRenderBundleFrameSetStyleOptimization(frameSet); syncQuakeRenderBundleRootVars(handle.element, firstFrame.renderBundle); @@ -313,7 +313,6 @@ export function mountQuakeRenderBundleFrameSetMesh( if (next.styleClassName) handle.element.classList.add(next.styleClassName); } currentFrameIndex = boundedNextFrameIndex; - syncQuakeRenderBundleFrameSetMetadata(handle.element, currentFrameIndex); syncQuakeRenderBundleDebugOutlineLeaves(handle.element, frameSetLeaves); return true; }; @@ -324,12 +323,6 @@ export function mountQuakeRenderBundleFrameSetMesh( return handle; } -function syncQuakeRenderBundleFrameSetMetadata(element: HTMLElement, frameIndex: number): void { - if (!isQuakeDebugDomMetadataEnabled()) return; - element.dataset.frameSet = "true"; - element.dataset.frameIndex = String(frameIndex); -} - export function setQuakeRenderBundleFrameSetHandleFrame(handle: PolyMeshHandle | null, frameIndex: number): boolean { if (!handle || !isQuakeRenderBundleFrameSetHandle(handle)) return false; return handle.setFrameIndex(frameIndex); @@ -428,7 +421,6 @@ function activateQuakeRenderBundleMotionMaterial( if (!quakeRenderBundleMotionMaterialActivationIsFromFrame(reason)) { state.phase = "scheduled"; state.deferAttempts = 0; - syncQuakeRenderBundleMotionMaterialDataset(element, state); markQuakeTrace("renderbundle-motion-material", { phase: "scheduled", reason, @@ -450,7 +442,6 @@ function activateQuakeRenderBundleMotionMaterial( if (!summary.visibleLeaves && state.deferAttempts < QUAKE_MOTION_MATERIAL_DEFER_FRAME_COUNT) { state.phase = "deferred"; state.deferAttempts++; - syncQuakeRenderBundleMotionMaterialDataset(element, state); markQuakeTrace("renderbundle-motion-material", { phase: "deferred", reason, @@ -472,7 +463,6 @@ function activateQuakeRenderBundleMotionMaterial( state.deferAttempts = 0; if (!summary.targetLeaves) { state.phase = "restored"; - syncQuakeRenderBundleMotionMaterialDataset(element, state); markQuakeTrace("renderbundle-motion-material", { phase: "empty", reason, @@ -485,7 +475,6 @@ function activateQuakeRenderBundleMotionMaterial( } element.classList.add(QUAKE_MOTION_MATERIAL_ACTIVE_CLASS); state.phase = "motion"; - syncQuakeRenderBundleMotionMaterialDataset(element, state); markQuakeTrace("renderbundle-motion-material", { phase: "motion", reason, @@ -514,7 +503,6 @@ function prepareQuakeRenderBundleMotionMaterialTargets( ): QuakeRenderBundleMotionMaterialTargetSummary { for (const leaf of state.targetLeaves) { leaf.classList.remove(QUAKE_MOTION_MATERIAL_TARGET_CLASS); - delete leaf.dataset.quakeMotionMaterial; } const document = element.ownerDocument; const view = document.defaultView; @@ -541,12 +529,10 @@ function prepareQuakeRenderBundleMotionMaterialTargets( continue; } entry.leaf.classList.add(QUAKE_MOTION_MATERIAL_TARGET_CLASS); - entry.leaf.dataset.quakeMotionMaterial = "solid"; targetLeaves.push(entry.leaf); targetAreaPx += entry.areaPx; } state.targetLeaves = targetLeaves; - syncQuakeRenderBundleMotionMaterialDataset(element, state); const summary: QuakeRenderBundleMotionMaterialTargetSummary = { projectedAreaPct: (totalAreaPx / viewportAreaPx) * 100, solidProjectedAreaPct: (targetAreaPx / viewportAreaPx) * 100, @@ -579,7 +565,6 @@ function restoreQuakeRenderBundleMotionMaterialChunked( state.phase = "restoring"; state.deferAttempts = 0; state.chunkIndex = 0; - syncQuakeRenderBundleMotionMaterialDataset(element, state); const chunkSize = Math.max(1, Math.ceil(targets.length / QUAKE_MOTION_MATERIAL_CHUNK_COUNT)); markQuakeTrace("renderbundle-motion-material", { phase: "restore-start", @@ -595,7 +580,6 @@ function restoreQuakeRenderBundleMotionMaterialChunked( for (let index = start; index < end; index++) { const leaf = targets[index]; leaf?.classList.remove(QUAKE_MOTION_MATERIAL_TARGET_CLASS); - if (leaf) delete leaf.dataset.quakeMotionMaterial; } state.chunkIndex++; markQuakeTrace("renderbundle-motion-material", { @@ -609,7 +593,6 @@ function restoreQuakeRenderBundleMotionMaterialChunked( finishQuakeRenderBundleMotionMaterialRestore(element, state, reason, targets.length); return; } - syncQuakeRenderBundleMotionMaterialDataset(element, state); state.chunkTimer = window.setTimeout(restoreNextChunk, state.intervalMs); }; restoreNextChunk(); @@ -624,14 +607,12 @@ function finishQuakeRenderBundleMotionMaterialRestore( clearQuakeRenderBundleMotionMaterialTimers(state); for (const leaf of state.targetLeaves) { leaf.classList.remove(QUAKE_MOTION_MATERIAL_TARGET_CLASS); - delete leaf.dataset.quakeMotionMaterial; } element.classList.remove(QUAKE_MOTION_MATERIAL_ACTIVE_CLASS); state.targetLeaves = []; state.phase = "restored"; state.deferAttempts = 0; state.chunkIndex = 0; - syncQuakeRenderBundleMotionMaterialDataset(element, state); markQuakeTrace("renderbundle-motion-material", { phase: "restored", reason, @@ -643,7 +624,6 @@ function clearQuakeRenderBundleMotionMaterial(element: HTMLElement, state: Quake clearQuakeRenderBundleMotionMaterialTimers(state); for (const leaf of state.targetLeaves) { leaf.classList.remove(QUAKE_MOTION_MATERIAL_TARGET_CLASS); - delete leaf.dataset.quakeMotionMaterial; } state.targetLeaves = []; state.phase = "restored"; @@ -651,8 +631,6 @@ function clearQuakeRenderBundleMotionMaterial(element: HTMLElement, state: Quake state.chunkIndex = 0; element.classList.remove(QUAKE_MOTION_MATERIAL_ACTIVE_CLASS); element.style.removeProperty("--quake-motion-material-solid-background"); - delete element.dataset.motionMaterialPhase; - delete element.dataset.motionMaterialTargetLeaves; } function clearQuakeRenderBundleMotionMaterialTimers(state: QuakeRenderBundleMotionMaterialState): void { @@ -670,15 +648,6 @@ function clearQuakeRenderBundleMotionMaterialTimers(state: QuakeRenderBundleMoti } } -function syncQuakeRenderBundleMotionMaterialDataset( - element: HTMLElement, - state: QuakeRenderBundleMotionMaterialState, -): void { - if (!isQuakeDebugDomMetadataEnabled()) return; - element.dataset.motionMaterialPhase = state.phase; - element.dataset.motionMaterialTargetLeaves = String(state.targetLeaves.length); -} - function quakeRenderBundleClippedRectAreaPx(rect: DOMRect, viewportWidth: number, viewportHeight: number): number { const left = Math.max(0, Math.min(viewportWidth, rect.left)); const top = Math.max(0, Math.min(viewportHeight, rect.top)); @@ -1048,19 +1017,11 @@ function applyQuakeRenderBundleDebugOutlineLeaves( ) { continue; } - if (options.hideTextures === true) { - leaf.removeAttribute("data-quake-debug-texture-hidden"); - leaf.style.backgroundImage = image; - leaf.style.backgroundPosition = layeredPosition; - leaf.style.backgroundSize = layeredSize; - leaf.style.backgroundRepeat = repeat; - } else { - leaf.removeAttribute("data-quake-debug-texture-hidden"); - leaf.style.backgroundImage = image; - leaf.style.backgroundPosition = layeredPosition; - leaf.style.backgroundSize = layeredSize; - leaf.style.backgroundRepeat = repeat; - } + renderBundleDebugHiddenTextureLeaves.delete(leaf); + leaf.style.backgroundImage = image; + leaf.style.backgroundPosition = layeredPosition; + leaf.style.backgroundSize = layeredSize; + leaf.style.backgroundRepeat = repeat; } } @@ -1109,12 +1070,12 @@ function quakeRenderBundleDebugLeafOverrideIsApplied(leaf: HTMLElement): boolean } function quakeRenderBundleDebugLeafTextureIsHidden(leaf: HTMLElement): boolean { - return leaf.dataset.quakeDebugTextureHidden === "true" && leaf.style.backgroundImage === "none"; + return renderBundleDebugHiddenTextureLeaves.has(leaf) && leaf.style.backgroundImage === "none"; } function hideQuakeRenderBundleDebugLeafTexture(leaf: HTMLElement): void { if (quakeRenderBundleDebugLeafTextureIsHidden(leaf)) return; - leaf.dataset.quakeDebugTextureHidden = "true"; + renderBundleDebugHiddenTextureLeaves.add(leaf); leaf.style.background = "none"; } @@ -1129,7 +1090,7 @@ function restoreQuakeRenderBundleDebugOutlineLeafBackgrounds(element: HTMLElemen function restoreQuakeRenderBundleDebugOutlineLeafBackground(leaf: HTMLElement): void { const previous = renderBundleDebugLeafBackgrounds.get(leaf); - leaf.removeAttribute("data-quake-debug-texture-hidden"); + renderBundleDebugHiddenTextureLeaves.delete(leaf); if (!previous) return; leaf.style.removeProperty("background"); leaf.style.removeProperty("background-image"); diff --git a/src/runtime/shootables/prewarm.ts b/src/runtime/shootables/prewarm.ts index 564f821..f7a0deb 100644 --- a/src/runtime/shootables/prewarm.ts +++ b/src/runtime/shootables/prewarm.ts @@ -8,6 +8,9 @@ interface QuakePrewarmShootableState { visible: boolean; } +const QUAKE_SHOOTABLE_PREWARM_MIN_IDLE_MS = 4; +const QUAKE_SHOOTABLE_PREWARM_MAX_DRAIN_PER_CALLBACK = 3; + export interface QuakeShootablePrewarmQueues { animationFrameQueueLength(): number; cancel(): void; @@ -92,8 +95,9 @@ export function createQuakeShootablePrewarmQueues 0) { const entityIndex = prewarmQueue.shift() as number; queuedPrewarmIndexes.delete(entityIndex); @@ -103,7 +107,8 @@ export function createQuakeShootablePrewarmQueues= QUAKE_SHOOTABLE_PREWARM_MAX_DRAIN_PER_CALLBACK || !canContinuePrewarmDrain(deadline)) break; } if (prewarmQueue.length > 0) schedulePrewarmDrain(); } @@ -143,8 +148,9 @@ export function createQuakeShootablePrewarmQueues 0) { const item = animationFramePrewarmQueue.shift(); if (!item) break; @@ -154,11 +160,16 @@ export function createQuakeShootablePrewarmQueues= QUAKE_SHOOTABLE_PREWARM_MAX_DRAIN_PER_CALLBACK || !canContinuePrewarmDrain(deadline)) break; } if (animationFramePrewarmQueue.length > 0) scheduleAnimationFramePrewarmDrain(); } + function canContinuePrewarmDrain(deadline: QuakeIdleDeadline): boolean { + return deadline.didTimeout || deadline.timeRemaining() >= QUAKE_SHOOTABLE_PREWARM_MIN_IDLE_MS; + } + function animationFramePrewarmKey(entityIndex: number, frameIndex: number): string { return `${entityIndex}:${frameIndex}`; } diff --git a/src/runtime/viewmodel.ts b/src/runtime/viewmodel.ts index a1c61b4..43b8966 100644 --- a/src/runtime/viewmodel.ts +++ b/src/runtime/viewmodel.ts @@ -2,19 +2,16 @@ import { buildPolySceneTransform, polyCssDistanceToWorld, type PolyFirstPersonControlsHandle, + type PolyMeshHandle, type PolySceneHandle, type Vec3, worldPositionToPolyCss, } from "@layoutit/polycss"; -import { QUAKE_COLLISION_UNIT_SCALE } from "./constants"; +import type { QuakePreparedRenderBundle } from "../prepare/scene"; +import { COLLISION_EPSILON, QUAKE_COLLISION_UNIT_SCALE } from "./constants"; import { crossVec3, normalizeVec3 } from "./math"; -import { - createQuakeViewmodelRasterLayer, - type QuakeViewmodelRasterLayer, - type QuakeViewmodelRasterModel, - type QuakeViewmodelRasterPostTransform, -} from "./viewmodelRaster"; +import { mountQuakeRenderBundleMesh, stripPolyMeshMetadata } from "./renderBundleMesh"; export interface QuakeViewmodelController { mount(model: QuakeViewmodelModel): void; @@ -57,15 +54,6 @@ export interface QuakeViewmodelTuning { screenYOffsetPx?: number; screenScaleX?: number; screenScaleY?: number; - rasterPostOriginXPx?: number; - rasterPostOriginYPx?: number; - rasterPostTranslateXPx?: number; - rasterPostTranslateYPx?: number; - rasterPostRotateDeg?: number; - rasterPostSkewXDeg?: number; - rasterPostSkewYDeg?: number; - rasterPostScaleX?: number; - rasterPostScaleY?: number; perspectiveScale?: number; stageOffsetPx?: number; perspectiveOriginXOffsetPx?: number; @@ -76,7 +64,7 @@ export type QuakeResolvedViewmodelTuning = Required; export interface QuakeViewmodelModel { source: string; - rasterModel: QuakeViewmodelRasterModel; + renderBundle: QuakePreparedRenderBundle; } export interface QuakeViewmodelDebugSnapshot { @@ -193,15 +181,6 @@ const QUAKE_WEAPON_DEFAULT_TUNING: QuakeResolvedViewmodelTuning = { screenYOffsetPx: 12.5, screenScaleX: 0.98, screenScaleY: 1, - rasterPostOriginXPx: 640, - rasterPostOriginYPx: 360, - rasterPostTranslateXPx: 0, - rasterPostTranslateYPx: 0, - rasterPostRotateDeg: 0, - rasterPostSkewXDeg: 0, - rasterPostSkewYDeg: 0, - rasterPostScaleX: 1, - rasterPostScaleY: 1, perspectiveScale: 0.8, stageOffsetPx: QUAKE_WEAPON_REFERENCE_STAGE_OFFSET_PX, perspectiveOriginXOffsetPx: 0, @@ -214,15 +193,6 @@ const QUAKE_WEAPON_MODEL_TUNING_OVERRIDES: Record screenYOffsetPx: 15, screenScaleX: 0.866, screenScaleY: 0.972, - // The axe head exposes a small residual canvas/vkQuake alias projection drift. - rasterPostOriginXPx: 767, - rasterPostOriginYPx: 455, - rasterPostTranslateXPx: -1.3, - rasterPostTranslateYPx: 1.1, - rasterPostRotateDeg: -1.5, - rasterPostSkewYDeg: -6.5, - rasterPostScaleX: 0.97, - rasterPostScaleY: 1.03, }, "progs/v_shot2.mdl": { screenScaleX: 1.149, @@ -247,12 +217,6 @@ const QUAKE_WEAPON_MODEL_TUNING_OVERRIDES: Record "progs/v_light.mdl": { screenScaleX: 0.916, screenScaleY: 0.922, - rasterPostOriginXPx: 637, - rasterPostOriginYPx: 508, - rasterPostTranslateXPx: 0.6, - rasterPostTranslateYPx: 1.8, - rasterPostScaleX: 0.967, - rasterPostScaleY: 0.995, }, }; const QUAKE_WEAPON_SCREEN_ROT_X = 90; @@ -283,7 +247,7 @@ export function createQuakeViewmodelController({ layer, }: QuakeViewmodelControllerOptions): QuakeViewmodelController { const stage = layer ? createQuakeViewmodelStage(layer) : null; - const raster: QuakeViewmodelRasterLayer | null = layer ? createQuakeViewmodelRasterLayer(layer) : null; + let handle: PolyMeshHandle | null = null; let carrier: HTMLElement | null = null; let viewportSyncFrame = 0; let cachedLayerScale = 1; @@ -319,13 +283,17 @@ export function createQuakeViewmodelController({ clearFireAnimation(); resetWalkBob(); invalidateViewportLayer(); - carrier?.remove(); + handle?.remove(); + handle = null; + carrier = null; if (!stage) throw new Error("Quake viewmodel mount requires a viewmodel stage."); - carrier = createQuakeViewmodelTransformCarrier(stage); + handle = mountQuakeRenderBundleMesh(stage, model.renderBundle); + carrier = handle.element; + carrier.classList.add("viewmodel", "quake-viewmodel-transform"); + stripPolyMeshMetadata(carrier); appliedLocalTransform = ""; - if (!raster) throw new Error("Quake viewmodel raster mount requires a viewmodel layer."); - raster.mount(model.rasterModel); mountedSource = source; + prepareNozzleLeaves(); syncTransform(); setNozzleVisible(false); } @@ -333,9 +301,9 @@ export function createQuakeViewmodelController({ function remove(): void { clearFireAnimation(); resetWalkBob(); - carrier?.remove(); + handle?.remove(); + handle = null; carrier = null; - raster?.remove(); appliedLocalTransform = ""; mountedSource = null; } @@ -440,7 +408,6 @@ export function createQuakeViewmodelController({ const weapon = weaponTransform(origin, rotX, rotY, bob); syncCarrierTransform(weapon); syncLayer(); - syncRasterLayer(rotY); } function queueViewportSync(): void { @@ -513,29 +480,29 @@ export function createQuakeViewmodelController({ } function setNozzleVisible(visible: boolean): void { - setWeaponFrameIndex(visible ? 1 : 0); + if (!carrier) return; + carrier.classList.toggle("quake-nozzle-visible", visible); } function setWeaponFrameIndex(frameIndex: number): void { - raster?.setFrameIndex(frameIndex); + setNozzleVisible(frameIndex > 0); } - function syncRasterLayer(rotY: number): void { - if (!raster || !layer || !stage || !carrier) return; - const currentTuning = activeTuning(); - raster.sync({ - width: QUAKE_WEAPON_REFERENCE_VIEWPORT_WIDTH_PX, - height: QUAKE_WEAPON_REFERENCE_VIEWPORT_HEIGHT_PX, - stageLeftPx: QUAKE_WEAPON_REFERENCE_VIEWPORT_WIDTH_PX / 2, - stageTopPx: QUAKE_WEAPON_REFERENCE_VIEWPORT_HEIGHT_PX / 2 + currentTuning.stageOffsetPx, - stageTransform: stage.style.transform, - meshTransform: carrier.style.transform, - perspectivePx: weaponPerspectivePx(), - perspectiveOriginX: QUAKE_WEAPON_REFERENCE_VIEWPORT_WIDTH_PX / 2 + currentTuning.perspectiveOriginXOffsetPx, - perspectiveOriginY: QUAKE_WEAPON_REFERENCE_VIEWPORT_HEIGHT_PX / 2 + currentTuning.perspectiveOriginYOffsetPx, - rotY, - postTransform: weaponRasterPostTransform(currentTuning), - }); + function prepareNozzleLeaves(): void { + if (!carrier) return; + let nozzleGroup = carrier.querySelector(".quake-nozzle-group"); + if (!nozzleGroup) { + nozzleGroup = carrier.ownerDocument.createElement("span"); + nozzleGroup.className = "quake-nozzle-group"; + } + for (const leaf of carrier.querySelectorAll("[data-weapon]")) { + leaf.removeAttribute("data-weapon"); + } + for (const leaf of carrier.querySelectorAll("[data-nozzle]")) { + nozzleGroup.appendChild(leaf); + leaf.removeAttribute("data-nozzle"); + } + carrier.appendChild(nozzleGroup); } function updateWalkBob(origin: Vec3): number { @@ -549,6 +516,9 @@ export function createQuakeViewmodelController({ const elapsed = (now - walkBobAt) / 1000; const horizontalDistance = Math.hypot(origin[0] - walkBobOrigin[0], origin[1] - walkBobOrigin[1]); syncWalkBobOrigin(origin, now); + if (horizontalDistance <= COLLISION_EPSILON && elapsed < QUAKE_WEAPON_BOB_MIN_DT) { + return walkBob; + } if ( !Number.isFinite(elapsed) || elapsed <= 0 || @@ -802,31 +772,6 @@ function weaponLocalTransform(tuning: QuakeResolvedViewmodelTuning): string { ].filter(Boolean).join(" "); } -function weaponRasterPostTransform(tuning: QuakeResolvedViewmodelTuning): QuakeViewmodelRasterPostTransform | null { - if ( - Math.abs(tuning.rasterPostTranslateXPx) <= 0.001 && - Math.abs(tuning.rasterPostTranslateYPx) <= 0.001 && - Math.abs(tuning.rasterPostRotateDeg) <= 0.001 && - Math.abs(tuning.rasterPostSkewXDeg) <= 0.001 && - Math.abs(tuning.rasterPostSkewYDeg) <= 0.001 && - Math.abs(tuning.rasterPostScaleX - 1) <= 0.001 && - Math.abs(tuning.rasterPostScaleY - 1) <= 0.001 - ) { - return null; - } - return { - originX: tuning.rasterPostOriginXPx, - originY: tuning.rasterPostOriginYPx, - translateX: tuning.rasterPostTranslateXPx, - translateY: tuning.rasterPostTranslateYPx, - rotateDeg: tuning.rasterPostRotateDeg, - skewXDeg: tuning.rasterPostSkewXDeg, - skewYDeg: tuning.rasterPostSkewYDeg, - scaleX: tuning.rasterPostScaleX, - scaleY: tuning.rasterPostScaleY, - }; -} - function weaponTransformCss(weapon: QuakeViewmodelDebugSnapshot["weapon"]): string { const [x, y, z] = weapon.position; const cssPosition = worldPositionToPolyCss([x, y, z]); @@ -955,19 +900,11 @@ function roundDebugNumber(value: number, decimals = 4): number { function createQuakeViewmodelStage(layer: HTMLElement): HTMLElement { const stage = document.createElement("div"); - stage.id = "quake-viewmodel-stage"; - stage.className = "polycss-scene"; + stage.className = "quake-weapon-stage polycss-scene"; layer.appendChild(stage); return stage; } -function createQuakeViewmodelTransformCarrier(stage: HTMLElement): HTMLElement { - const carrier = stage.ownerDocument.createElement("div"); - carrier.className = "polycss-mesh viewmodel quake-viewmodel-transform"; - stage.appendChild(carrier); - return carrier; -} - function forwardDirection(rotX: number, rotY: number): Vec3 { const rx = (rotX * Math.PI) / 180; const ry = (rotY * Math.PI) / 180; diff --git a/src/runtime/viewmodelRaster.ts b/src/runtime/viewmodelRaster.ts deleted file mode 100644 index 03f2fa8..0000000 --- a/src/runtime/viewmodelRaster.ts +++ /dev/null @@ -1,629 +0,0 @@ -import { type Vec3, worldPositionToPolyCss } from "@layoutit/polycss"; - -export interface QuakeViewmodelRasterModel { - version: 1; - source: string; - skinWidth: number; - skinHeight: number; - skin: string; - palette: string; - triangles: QuakeViewmodelRasterTriangle[]; - frames: QuakeViewmodelRasterFrame[]; -} - -export interface QuakeViewmodelRasterTriangle { - facesfront: boolean; - indices: [number, number, number]; - uvs: [number, number][]; -} - -export interface QuakeViewmodelRasterFrame { - name: string; - vertices: Vec3[]; - normalIndices: number[]; -} - -export interface QuakeViewmodelRasterSyncState { - width: number; - height: number; - stageLeftPx: number; - stageTopPx: number; - stageTransform: string; - meshTransform: string; - perspectivePx: number; - perspectiveOriginX: number; - perspectiveOriginY: number; - rotY: number; - postTransform?: QuakeViewmodelRasterPostTransform | null; -} - -export interface QuakeViewmodelRasterPostTransform { - originX: number; - originY: number; - translateX: number; - translateY: number; - rotateDeg: number; - skewXDeg: number; - skewYDeg: number; - scaleX: number; - scaleY: number; -} - -export interface QuakeViewmodelRasterLayer { - mount(model: QuakeViewmodelRasterModel): void; - remove(): void; - sync(state: QuakeViewmodelRasterSyncState): void; - setFrameIndex(frameIndex: number): void; -} - -interface DecodedQuakeViewmodelRasterModel { - source: string; - skinWidth: number; - skinHeight: number; - skin: Uint8Array; - palette: Uint8Array; - skinR: Uint8Array; - skinG: Uint8Array; - skinB: Uint8Array; - skinFullbright: Uint8Array; - triangles: QuakeViewmodelRasterTriangle[]; - frames: QuakeViewmodelRasterFrame[]; -} - -interface ProjectedVertex { - x: number; - y: number; - z: number; - q: number; - shade: number; -} - -interface ProjectedTriangle { - triangle: QuakeViewmodelRasterTriangle; - vertices: [ProjectedVertex, ProjectedVertex, ProjectedVertex]; -} - -interface RasterDirtyRect { - x: number; - y: number; - width: number; - height: number; -} - -interface RasterBuffer { - image: ImageData; - zBuffer: Float32Array; - width: number; - height: number; -} - -const QUAKE_ALIAS_VIEWMODEL_LIGHT_COLOR = 96 / 200; -const QUAKE_ALIAS_SHADER_LIGHT_SCALE = 2; -const QUAKE_RASTER_POSTPROCESS_GAMMA = 0.9; -const QUAKE_RASTER_POSTPROCESS_CONTRAST = 1.4; -const QUAKE_RASTER_MIN_DENOMINATOR = 1e-3; -const QUAKE_RASTER_EDGE_EPSILON = 1e-5; -const QUAKE_ALIAS_NORMALS = decodeAliasNormals([ - "-0.525731,0.000000,0.850651,-0.442863,0.238856,0.864188,-0.295242,0.000000,0.955423,-0.309017,", - "0.500000,0.809017,-0.162460,0.262866,0.951056,0.000000,0.000000,1.000000,0.000000,0.850651,0.5", - "25731,-0.147621,0.716567,0.681718,0.147621,0.716567,0.681718,0.000000,0.525731,0.850651,0.3090", - "17,0.500000,0.809017,0.525731,0.000000,0.850651,0.295242,0.000000,0.955423,0.442863,0.238856,0", - ".864188,0.162460,0.262866,0.951056,-0.681718,0.147621,0.716567,-0.809017,0.309017,0.500000,-0.", - "587785,0.425325,0.688191,-0.850651,0.525731,0.000000,-0.864188,0.442863,0.238856,-0.716567,0.6", - "81718,0.147621,-0.688191,0.587785,0.425325,-0.500000,0.809017,0.309017,-0.238856,0.864188,0.44", - "2863,-0.425325,0.688191,0.587785,-0.716567,0.681718,-0.147621,-0.500000,0.809017,-0.309017,-0.", - "525731,0.850651,0.000000,0.000000,0.850651,-0.525731,-0.238856,0.864188,-0.442863,0.000000,0.9", - "55423,-0.295242,-0.262866,0.951056,-0.162460,0.000000,1.000000,0.000000,0.000000,0.955423,0.29", - "5242,-0.262866,0.951056,0.162460,0.238856,0.864188,0.442863,0.262866,0.951056,0.162460,0.50000", - "0,0.809017,0.309017,0.238856,0.864188,-0.442863,0.262866,0.951056,-0.162460,0.500000,0.809017,", - "-0.309017,0.850651,0.525731,0.000000,0.716567,0.681718,0.147621,0.716567,0.681718,-0.147621,0.", - "525731,0.850651,0.000000,0.425325,0.688191,0.587785,0.864188,0.442863,0.238856,0.688191,0.5877", - "85,0.425325,0.809017,0.309017,0.500000,0.681718,0.147621,0.716567,0.587785,0.425325,0.688191,0", - ".955423,0.295242,0.000000,1.000000,0.000000,0.000000,0.951056,0.162460,0.262866,0.850651,-0.52", - "5731,0.000000,0.955423,-0.295242,0.000000,0.864188,-0.442863,0.238856,0.951056,-0.162460,0.262", - "866,0.809017,-0.309017,0.500000,0.681718,-0.147621,0.716567,0.850651,0.000000,0.525731,0.86418", - "8,0.442863,-0.238856,0.809017,0.309017,-0.500000,0.951056,0.162460,-0.262866,0.525731,0.000000", - ",-0.850651,0.681718,0.147621,-0.716567,0.681718,-0.147621,-0.716567,0.850651,0.000000,-0.52573", - "1,0.809017,-0.309017,-0.500000,0.864188,-0.442863,-0.238856,0.951056,-0.162460,-0.262866,0.147", - "621,0.716567,-0.681718,0.309017,0.500000,-0.809017,0.425325,0.688191,-0.587785,0.442863,0.2388", - "56,-0.864188,0.587785,0.425325,-0.688191,0.688191,0.587785,-0.425325,-0.147621,0.716567,-0.681", - "718,-0.309017,0.500000,-0.809017,0.000000,0.525731,-0.850651,-0.525731,0.000000,-0.850651,-0.4", - "42863,0.238856,-0.864188,-0.295242,0.000000,-0.955423,-0.162460,0.262866,-0.951056,0.000000,0.", - "000000,-1.000000,0.295242,0.000000,-0.955423,0.162460,0.262866,-0.951056,-0.442863,-0.238856,-", - "0.864188,-0.309017,-0.500000,-0.809017,-0.162460,-0.262866,-0.951056,0.000000,-0.850651,-0.525", - "731,-0.147621,-0.716567,-0.681718,0.147621,-0.716567,-0.681718,0.000000,-0.525731,-0.850651,0.", - "309017,-0.500000,-0.809017,0.442863,-0.238856,-0.864188,0.162460,-0.262866,-0.951056,0.238856,", - "-0.864188,-0.442863,0.500000,-0.809017,-0.309017,0.425325,-0.688191,-0.587785,0.716567,-0.6817", - "18,-0.147621,0.688191,-0.587785,-0.425325,0.587785,-0.425325,-0.688191,0.000000,-0.955423,-0.2", - "95242,0.000000,-1.000000,0.000000,0.262866,-0.951056,-0.162460,0.000000,-0.850651,0.525731,0.0", - "00000,-0.955423,0.295242,0.238856,-0.864188,0.442863,0.262866,-0.951056,0.162460,0.500000,-0.8", - "09017,0.309017,0.716567,-0.681718,0.147621,0.525731,-0.850651,0.000000,-0.238856,-0.864188,-0.", - "442863,-0.500000,-0.809017,-0.309017,-0.262866,-0.951056,-0.162460,-0.850651,-0.525731,0.00000", - "0,-0.716567,-0.681718,-0.147621,-0.716567,-0.681718,0.147621,-0.525731,-0.850651,0.000000,-0.5", - "00000,-0.809017,0.309017,-0.238856,-0.864188,0.442863,-0.262866,-0.951056,0.162460,-0.864188,-", - "0.442863,0.238856,-0.809017,-0.309017,0.500000,-0.688191,-0.587785,0.425325,-0.681718,-0.14762", - "1,0.716567,-0.442863,-0.238856,0.864188,-0.587785,-0.425325,0.688191,-0.309017,-0.500000,0.809", - "017,-0.147621,-0.716567,0.681718,-0.425325,-0.688191,0.587785,-0.162460,-0.262866,0.951056,0.4", - "42863,-0.238856,0.864188,0.162460,-0.262866,0.951056,0.309017,-0.500000,0.809017,0.147621,-0.7", - "16567,0.681718,0.000000,-0.525731,0.850651,0.425325,-0.688191,0.587785,0.587785,-0.425325,0.68", - "8191,0.688191,-0.587785,0.425325,-0.955423,0.295242,0.000000,-0.951056,0.162460,0.262866,-1.00", - "0000,0.000000,0.000000,-0.850651,0.000000,0.525731,-0.955423,-0.295242,0.000000,-0.951056,-0.1", - "62460,0.262866,-0.864188,0.442863,-0.238856,-0.951056,0.162460,-0.262866,-0.809017,0.309017,-0", - ".500000,-0.864188,-0.442863,-0.238856,-0.951056,-0.162460,-0.262866,-0.809017,-0.309017,-0.500", - "000,-0.681718,0.147621,-0.716567,-0.681718,-0.147621,-0.716567,-0.850651,0.000000,-0.525731,-0", - ".688191,0.587785,-0.425325,-0.587785,0.425325,-0.688191,-0.425325,0.688191,-0.587785,-0.425325", - ",-0.688191,-0.587785,-0.587785,-0.425325,-0.688191,-0.688191,-0.587785,-0.425325", -].join("")); - -export function createQuakeViewmodelRasterLayer(layer: HTMLElement): QuakeViewmodelRasterLayer { - const canvas = layer.ownerDocument.createElement("canvas"); - const context = canvas.getContext("2d"); - let decoded: DecodedQuakeViewmodelRasterModel | null = null; - let currentState: QuakeViewmodelRasterSyncState | null = null; - let rasterBuffer: RasterBuffer | null = null; - let frameIndex = 0; - - canvas.id = "quake-viewmodel-raster"; - canvas.width = 1; - canvas.height = 1; - canvas.style.left = "0px"; - canvas.style.top = "0px"; - canvas.style.right = "auto"; - canvas.style.bottom = "auto"; - canvas.style.width = "1px"; - canvas.style.height = "1px"; - - function mount(model: QuakeViewmodelRasterModel): void { - if (!context) throw new Error("Quake viewmodel raster requires a 2D canvas context."); - decoded = decodeRasterModel(model); - frameIndex = 0; - if (canvas.parentElement !== layer) layer.appendChild(canvas); - layer.classList.add("quake-viewmodel-raster-active"); - if (currentState) draw(currentState); - } - - function remove(): void { - decoded = null; - currentState = null; - frameIndex = 0; - layer.classList.remove("quake-viewmodel-raster-active"); - canvas.remove(); - } - - function sync(state: QuakeViewmodelRasterSyncState): void { - currentState = state; - if (!decoded || !context || canvas.parentElement !== layer) return; - draw(state); - } - - function setFrameIndex(nextFrameIndex: number): void { - frameIndex = Math.max(0, Math.floor(nextFrameIndex)); - if (currentState) draw(currentState); - } - - function draw(state: QuakeViewmodelRasterSyncState): void { - if (!decoded || !context) return; - const width = Math.max(1, Math.round(state.width)); - const height = Math.max(1, Math.round(state.height)); - - let meshMatrix: DOMMatrix; - let stageMatrix: DOMMatrix; - try { - meshMatrix = cssTransformMatrix(state.meshTransform); - stageMatrix = cssTransformMatrix(state.stageTransform); - } catch { - context.clearRect(0, 0, canvas.width, canvas.height); - return; - } - - const frame = decoded.frames[Math.min(frameIndex, decoded.frames.length - 1)] ?? decoded.frames[0]; - if (!frame) { - context.clearRect(0, 0, canvas.width, canvas.height); - return; - } - const shadeVector = quakeAliasShadeVector(state.rotY); - const projectedVertices: (ProjectedVertex | null)[] = new Array(frame.vertices.length); - for (let vertexIndex = 0; vertexIndex < frame.vertices.length; vertexIndex++) { - projectedVertices[vertexIndex] = projectVertex( - frame.vertices[vertexIndex], - meshMatrix, - stageMatrix, - state, - quakeAliasVertexLight(frame.normalIndices[vertexIndex], shadeVector), - ); - } - - const projectedTriangles: ProjectedTriangle[] = []; - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (let triangleIndex = 0; triangleIndex < decoded.triangles.length; triangleIndex++) { - const triangle = decoded.triangles[triangleIndex]; - const v0 = projectedVertices[triangle.indices[0]]; - const v1 = projectedVertices[triangle.indices[1]]; - const v2 = projectedVertices[triangle.indices[2]]; - if (!v0 || !v1 || !v2) continue; - const vertices: [ProjectedVertex, ProjectedVertex, ProjectedVertex] = [v0, v1, v2]; - projectedTriangles.push({ triangle, vertices }); - for (const vertex of vertices) { - minX = Math.min(minX, vertex.x); - minY = Math.min(minY, vertex.y); - maxX = Math.max(maxX, vertex.x); - maxY = Math.max(maxY, vertex.y); - } - } - - const dirtyRect = rasterDirtyRect(minX, minY, maxX, maxY, width, height); - if (!dirtyRect) { - context.clearRect(0, 0, canvas.width, canvas.height); - return; - } - - rasterBuffer = rasterBufferFor(context, rasterBuffer, dirtyRect.width, dirtyRect.height); - syncCanvasBounds(canvas, rasterBuffer, dirtyRect); - syncCanvasPostTransform(canvas, state.postTransform ?? null, dirtyRect); - context.clearRect(0, 0, canvas.width, canvas.height); - clearRasterBuffer(rasterBuffer, dirtyRect.width, dirtyRect.height); - for (const projected of projectedTriangles) { - drawTriangle( - rasterBuffer.image.data, - rasterBuffer.zBuffer, - rasterBuffer.width, - dirtyRect.width, - dirtyRect.height, - decoded, - projected.triangle, - projected.vertices, - dirtyRect.x, - dirtyRect.y, - ); - } - context.putImageData(rasterBuffer.image, 0, 0, 0, 0, dirtyRect.width, dirtyRect.height); - } - - return { mount, remove, sync, setFrameIndex }; -} - -function decodeRasterModel(model: QuakeViewmodelRasterModel): DecodedQuakeViewmodelRasterModel { - const skin = decodeBase64Bytes(model.skin); - const palette = decodeBase64Bytes(model.palette); - const texelCount = model.skinWidth * model.skinHeight; - const skinR = new Uint8Array(texelCount); - const skinG = new Uint8Array(texelCount); - const skinB = new Uint8Array(texelCount); - const skinFullbright = new Uint8Array(texelCount); - for (let index = 0; index < texelCount; index++) { - const skinIndex = skin[index] ?? 0; - const paletteIndex = skinIndex * 3; - skinR[index] = palette[paletteIndex] ?? 0; - skinG[index] = palette[paletteIndex + 1] ?? 0; - skinB[index] = palette[paletteIndex + 2] ?? 0; - skinFullbright[index] = skinIndex >= 224 ? 1 : 0; - } - - return { - source: model.source, - skinWidth: model.skinWidth, - skinHeight: model.skinHeight, - skin, - palette, - skinR, - skinG, - skinB, - skinFullbright, - triangles: model.triangles, - frames: model.frames, - }; -} - -function projectVertex( - vertex: Vec3 | undefined, - meshMatrix: DOMMatrix, - stageMatrix: DOMMatrix, - state: QuakeViewmodelRasterSyncState, - shade: number, -): ProjectedVertex | null { - if (!vertex) return null; - const [x, y, z] = worldPositionToPolyCss(vertex); - const local = new DOMPoint(x, y, z, 1); - const transformed = local.matrixTransform(meshMatrix).matrixTransform(stageMatrix); - const layerX = state.stageLeftPx + transformed.x; - const layerY = state.stageTopPx + transformed.y; - const denominator = state.perspectivePx - transformed.z; - if (!Number.isFinite(denominator) || denominator <= QUAKE_RASTER_MIN_DENOMINATOR) return null; - const q = state.perspectivePx / denominator; - return { - x: state.perspectiveOriginX + (layerX - state.perspectiveOriginX) * q, - y: state.perspectiveOriginY + (layerY - state.perspectiveOriginY) * q, - z: transformed.z, - q, - shade, - }; -} - -function rasterDirtyRect( - minX: number, - minY: number, - maxX: number, - maxY: number, - width: number, - height: number, -): RasterDirtyRect | null { - if ( - !Number.isFinite(minX) || - !Number.isFinite(minY) || - !Number.isFinite(maxX) || - !Number.isFinite(maxY) - ) { - return null; - } - const left = clampInt(Math.floor(minX) - 2, 0, width); - const top = clampInt(Math.floor(minY) - 2, 0, height); - const right = clampInt(Math.ceil(maxX) + 2, 0, width); - const bottom = clampInt(Math.ceil(maxY) + 2, 0, height); - if (right <= left || bottom <= top) return null; - return { - x: left, - y: top, - width: right - left, - height: bottom - top, - }; -} - -function rasterBufferFor( - context: CanvasRenderingContext2D, - current: RasterBuffer | null, - width: number, - height: number, -): RasterBuffer { - if (current && current.width >= width && current.height >= height) return current; - const nextWidth = alignInt(Math.max(width, current?.width ?? 0), 32); - const nextHeight = alignInt(Math.max(height, current?.height ?? 0), 32); - return { - image: context.createImageData(nextWidth, nextHeight), - zBuffer: new Float32Array(nextWidth * nextHeight), - width: nextWidth, - height: nextHeight, - }; -} - -function syncCanvasBounds(canvas: HTMLCanvasElement, buffer: RasterBuffer, rect: RasterDirtyRect): void { - if (canvas.width !== buffer.width) canvas.width = buffer.width; - if (canvas.height !== buffer.height) canvas.height = buffer.height; - canvas.style.left = `${rect.x}px`; - canvas.style.top = `${rect.y}px`; - canvas.style.width = `${buffer.width}px`; - canvas.style.height = `${buffer.height}px`; -} - -function syncCanvasPostTransform( - canvas: HTMLCanvasElement, - transform: QuakeViewmodelRasterPostTransform | null, - rect: RasterDirtyRect, -): void { - if (!transform) { - canvas.style.removeProperty("transform"); - canvas.style.removeProperty("transform-origin"); - return; - } - canvas.style.transformOrigin = `${transform.originX - rect.x}px ${transform.originY - rect.y}px`; - const transforms = [ - Math.abs(transform.translateX) > 0.001 || Math.abs(transform.translateY) > 0.001 - ? `translate(${transform.translateX}px, ${transform.translateY}px)` - : "", - Math.abs(transform.rotateDeg) > 0.001 ? `rotate(${transform.rotateDeg}deg)` : "", - Math.abs(transform.skewXDeg) > 0.001 ? `skewX(${transform.skewXDeg}deg)` : "", - Math.abs(transform.skewYDeg) > 0.001 ? `skewY(${transform.skewYDeg}deg)` : "", - Math.abs(transform.scaleX - 1) > 0.001 || Math.abs(transform.scaleY - 1) > 0.001 - ? `scale(${transform.scaleX}, ${transform.scaleY})` - : "", - ]; - canvas.style.transform = transforms.filter(Boolean).join(" "); -} - -function clearRasterBuffer(buffer: RasterBuffer, width: number, height: number): void { - const rgbaStride = buffer.width * 4; - const rgbaRowBytes = width * 4; - const rgba = buffer.image.data; - const zBuffer = buffer.zBuffer; - if (width === buffer.width && height === buffer.height) { - rgba.fill(0); - zBuffer.fill(-Infinity); - return; - } - for (let y = 0; y < height; y++) { - const rgbaStart = y * rgbaStride; - rgba.fill(0, rgbaStart, rgbaStart + rgbaRowBytes); - const zStart = y * buffer.width; - zBuffer.fill(-Infinity, zStart, zStart + width); - } -} - -function drawTriangle( - rgba: Uint8ClampedArray, - zBuffer: Float32Array, - stride: number, - width: number, - height: number, - model: DecodedQuakeViewmodelRasterModel, - triangle: QuakeViewmodelRasterTriangle, - vertices: [ProjectedVertex, ProjectedVertex, ProjectedVertex], - offsetX = 0, - offsetY = 0, -): void { - const [a, b, c] = vertices; - const ax = a.x - offsetX; - const ay = a.y - offsetY; - const bx = b.x - offsetX; - const by = b.y - offsetY; - const cx = c.x - offsetX; - const cy = c.y - offsetY; - const area = edgeFunction(ax, ay, bx, by, cx, cy); - if (Math.abs(area) <= QUAKE_RASTER_EDGE_EPSILON) return; - const sign = area < 0 ? -1 : 1; - const invAreaAbs = 1 / Math.abs(area); - const minX = clampInt(Math.floor(Math.min(ax, bx, cx)), 0, width - 1); - const maxX = clampInt(Math.ceil(Math.max(ax, bx, cx)), 0, width - 1); - const minY = clampInt(Math.floor(Math.min(ay, by, cy)), 0, height - 1); - const maxY = clampInt(Math.ceil(Math.max(ay, by, cy)), 0, height - 1); - const uv0 = triangle.uvs[0] ?? [0, 0]; - const uv1 = triangle.uvs[1] ?? [0, 0]; - const uv2 = triangle.uvs[2] ?? [0, 0]; - const stepX0 = sign * (cy - by); - const stepY0 = sign * -(cx - bx); - const stepX1 = sign * (ay - cy); - const stepY1 = sign * -(ax - cx); - const stepX2 = sign * (by - ay); - const stepY2 = sign * -(bx - ax); - const startX = minX + 0.5; - let rowW0 = sign * edgeFunction(bx, by, cx, cy, startX, minY + 0.5); - let rowW1 = sign * edgeFunction(cx, cy, ax, ay, startX, minY + 0.5); - let rowW2 = sign * edgeFunction(ax, ay, bx, by, startX, minY + 0.5); - - for (let y = minY; y <= maxY; y++) { - let w0 = rowW0; - let w1 = rowW1; - let w2 = rowW2; - for (let x = minX; x <= maxX; x++) { - if (w0 < -QUAKE_RASTER_EDGE_EPSILON || w1 < -QUAKE_RASTER_EDGE_EPSILON || w2 < -QUAKE_RASTER_EDGE_EPSILON) { - w0 += stepX0; - w1 += stepX1; - w2 += stepX2; - continue; - } - - const b0 = w0 * invAreaAbs; - const b1 = w1 * invAreaAbs; - const b2 = w2 * invAreaAbs; - const q0 = b0 * a.q; - const q1 = b1 * b.q; - const q2 = b2 * c.q; - const q = q0 + q1 + q2; - if (Math.abs(q) <= QUAKE_RASTER_EDGE_EPSILON) { - w0 += stepX0; - w1 += stepX1; - w2 += stepX2; - continue; - } - const z = (q0 * a.z + q1 * b.z + q2 * c.z) / q; - const pixelIndex = y * stride + x; - if (z <= zBuffer[pixelIndex]) { - w0 += stepX0; - w1 += stepX1; - w2 += stepX2; - continue; - } - zBuffer[pixelIndex] = z; - - const u = (q0 * uv0[0] + q1 * uv1[0] + q2 * uv2[0]) / q; - const v = (q0 * uv0[1] + q1 * uv1[1] + q2 * uv2[1]) / q; - const skinX = clampNumber(u * model.skinWidth - 0.5, 0, model.skinWidth - 1); - const skinY = clampNumber((1 - v) * model.skinHeight - 0.5, 0, model.skinHeight - 1); - const x0 = Math.floor(skinX); - const y0 = Math.floor(skinY); - const x1 = Math.min(model.skinWidth - 1, x0 + 1); - const y1 = Math.min(model.skinHeight - 1, y0 + 1); - const tx = skinX - x0; - const ty = skinY - y0; - const row0 = y0 * model.skinWidth; - const row1 = y1 * model.skinWidth; - const i00 = row0 + x0; - const i10 = row0 + x1; - const i01 = row1 + x0; - const i11 = row1 + x1; - const r = bilerpNumber(model.skinR[i00], model.skinR[i10], model.skinR[i01], model.skinR[i11], tx, ty); - const g = bilerpNumber(model.skinG[i00], model.skinG[i10], model.skinG[i01], model.skinG[i11], tx, ty); - const blue = bilerpNumber(model.skinB[i00], model.skinB[i10], model.skinB[i01], model.skinB[i11], tx, ty); - const fullbright = - model.skinFullbright[i00] || model.skinFullbright[i10] || model.skinFullbright[i01] || model.skinFullbright[i11]; - const light = fullbright ? 1 : (q0 * a.shade + q1 * b.shade + q2 * c.shade) / q; - const out = pixelIndex * 4; - rgba[out] = postprocessChannel(r, light); - rgba[out + 1] = postprocessChannel(g, light); - rgba[out + 2] = postprocessChannel(blue, light); - rgba[out + 3] = 255; - w0 += stepX0; - w1 += stepX1; - w2 += stepX2; - } - rowW0 += stepY0; - rowW1 += stepY1; - rowW2 += stepY2; - } -} - -function quakeAliasVertexLight(normalIndex: number | undefined, shadeVector: Vec3): number { - const normal = QUAKE_ALIAS_NORMALS[normalIndex ?? -1] ?? [0, 0, 1]; - const dot = normal[0] * shadeVector[0] + normal[1] * shadeVector[1] + normal[2] * shadeVector[2]; - const shadeDot = dot < 0 ? 1 + dot * (13 / 44) : 1 + dot; - return QUAKE_ALIAS_VIEWMODEL_LIGHT_COLOR * shadeDot * QUAKE_ALIAS_SHADER_LIGHT_SCALE; -} - -function quakeAliasShadeVector(rotY: number): Vec3 { - const quakeYaw = normalizeAngleDegrees(rotY - 180); - const quantized = Math.floor(quakeYaw * (16 / 360)) / 16; - const angle = quantized * Math.PI * 2; - return normalizeVec3([Math.cos(-angle), Math.sin(-angle), 1]); -} - -function postprocessChannel(value: number, light: number): number { - const lit = (value / 255) * light; - const contrasted = lit * QUAKE_RASTER_POSTPROCESS_CONTRAST; - const corrected = Math.pow(clampNumber(contrasted, 0, 1), QUAKE_RASTER_POSTPROCESS_GAMMA); - return clampInt(Math.round(corrected * 255), 0, 255); -} - -function cssTransformMatrix(transform: string): DOMMatrix { - const trimmed = transform.trim(); - return trimmed && trimmed !== "none" ? new DOMMatrix(trimmed) : new DOMMatrix(); -} - -function edgeFunction(ax: number, ay: number, bx: number, by: number, cx: number, cy: number): number { - return (cx - ax) * (by - ay) - (cy - ay) * (bx - ax); -} - -function decodeBase64Bytes(value: string): Uint8Array { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -function normalizeVec3(value: Vec3): Vec3 { - const length = Math.hypot(value[0], value[1], value[2]); - return length > 1e-8 ? [value[0] / length, value[1] / length, value[2] / length] : [0, 0, 1]; -} - -function normalizeAngleDegrees(value: number): number { - return ((value % 360) + 360) % 360; -} - -function decodeAliasNormals(value: string): Vec3[] { - const numbers = value.split(",").map((part) => Number.parseFloat(part)); - const normals: Vec3[] = []; - for (let index = 0; index + 2 < numbers.length; index += 3) { - normals.push(normalizeVec3([numbers[index], numbers[index + 1], numbers[index + 2]])); - } - return normals; -} - -function lerpNumber(a: number, b: number, t: number): number { - return a + (b - a) * t; -} - -function bilerpNumber(c00: number, c10: number, c01: number, c11: number, tx: number, ty: number): number { - return lerpNumber(lerpNumber(c00, c10, tx), lerpNumber(c01, c11, tx), ty); -} - -function clampNumber(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function clampInt(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value | 0)); -} - -function alignInt(value: number, alignment: number): number { - return Math.max(alignment, Math.ceil(value / alignment) * alignment); -} diff --git a/src/runtime/weapons.ts b/src/runtime/weapons.ts index 3008e4f..b5da97a 100644 --- a/src/runtime/weapons.ts +++ b/src/runtime/weapons.ts @@ -47,7 +47,7 @@ export interface QuakeWeaponsController { debugFireProjectile(options?: QuakeWeaponProjectileDebugFireOptions): boolean; debugProjectileImpact( weapon: QuakeWeaponId, - entityIndex: number, + entityIndex: number | null, origin: Vec3, directDamage?: number, ): QuakeWeaponProjectileImpactDebugResult | null; @@ -69,6 +69,27 @@ export interface QuakeWeaponFireEvent { range: number; } +export interface QuakeWeaponDamageImpactEvent { + damage: number; + direction: Vec3; + entityIndex: number; + fireKind: QuakeWeaponFireEvent["fireKind"]; + origin: Vec3; + targetKind: "shootable"; + weapon: QuakeWeaponId; +} + +export type QuakeWeaponWallImpactEffect = "gunshot" | "spike" | "superspike"; + +export interface QuakeWeaponWallImpactEvent { + direction: Vec3; + effect: QuakeWeaponWallImpactEffect; + fireKind: QuakeWeaponFireEvent["fireKind"]; + origin: Vec3; + targetKind: "world"; + weapon: QuakeWeaponId; +} + export interface QuakeWeaponsControllerOptions { scene: PolySceneHandle; controls: Pick; @@ -93,7 +114,9 @@ export interface QuakeWeaponsControllerOptions { canDamageTargetOrigin?(start: Vec3, targetOrigin: Vec3): boolean; damageMultiplier?: () => number; random?: () => number; + onDamageImpact?(event: QuakeWeaponDamageImpactEvent): void; onFire?(event: QuakeWeaponFireEvent): void; + onWallImpact?(event: QuakeWeaponWallImpactEvent): void; onHit(): void; showLightningBeam?(beam: QuakeWeaponLightningBeamVisual): void; syncCrosshairTarget(): void; @@ -113,7 +136,7 @@ export interface QuakeWeaponLightningBeamVisual { export interface QuakeWeaponProjectileImpactDebugResult { directDamage: number; - directEntityIndex: number; + directEntityIndex: number | null; directEntityClassname: string | null; impactResult: "keep" | "remove"; origin: Vec3; @@ -698,7 +721,9 @@ export function createQuakeWeaponsController({ canDamageTargetOrigin, damageMultiplier, random = Math.random, + onDamageImpact, onFire, + onWallImpact, onHit, showLightningBeam, syncCrosshairTarget, @@ -824,16 +849,16 @@ export function createQuakeWeaponsController({ function debugProjectileImpact( weapon: QuakeWeaponId, - entityIndex: number, + entityIndex: number | null, origin: Vec3, directDamage?: number, ): QuakeWeaponProjectileImpactDebugResult | null { const profile = QUAKE_WEAPON_FIRE_PROFILES[weapon]; - if (!Number.isFinite(entityIndex) || !vec3IsFinite(origin)) return null; + if ((entityIndex !== null && !Number.isFinite(entityIndex)) || !vec3IsFinite(origin)) return null; if (!profile || !quakeWeaponFireProfileIsRuntimeSupported(profile) || profile.kind !== "projectile") return null; - const directEntityIndex = Math.round(entityIndex); - const entity = getEntities().get(directEntityIndex); - if (!entity) return null; + const directEntityIndex = entityIndex === null ? null : Math.round(entityIndex); + const entity = directEntityIndex === null ? null : getEntities().get(directEntityIndex); + if (directEntityIndex !== null && !entity) return null; const damage = Number.isFinite(directDamage) ? Math.max(0, directDamage) : projectileDirectDamage(profile); const direction: Vec3 = [0, -1, 0]; const impactOrigin = [...origin] as Vec3; @@ -852,16 +877,18 @@ export function createQuakeWeaponsController({ velocity, }; const trace: QuakeProjectileTrace = { - classname: entity.classname, end: impactOrigin, - entityIndex: directEntityIndex, fraction: 0, planeNormal: null, }; + if (entity && directEntityIndex !== null) { + trace.classname = entity.classname; + trace.entityIndex = directEntityIndex; + } const impactResult = handleProjectileImpact(projectile, trace); return { directDamage: damage, - directEntityClassname: entity.classname ?? null, + directEntityClassname: entity?.classname ?? null, directEntityIndex, impactResult, origin: impactOrigin, @@ -1080,18 +1107,30 @@ export function createQuakeWeaponsController({ function fireShotgunPellets(profile: QuakeHitscanPelletFireProfile): boolean { const aim = weaponAimForFire(); const damageByEntity = new Map(); + let wallImpactTrace: QuakeUseTrace | null = null; const { right, up } = weaponSpreadAxes(); for (let pellet = 0; pellet < profile.pelletCount; pellet++) { const direction = spreadWeaponDirection(aim.direction, right, up, profile); const trace = traceWeaponRay(viewRayFromDirection(aim.ray.origin, direction, QUAKE_WEAPON_TRACE_RANGE)); - if (!traceIsShootable(trace) || trace.entityIndex === undefined) continue; - damageByEntity.set(trace.entityIndex, (damageByEntity.get(trace.entityIndex) ?? 0) + profile.pelletDamage); + if (!trace) continue; + if (traceIsShootable(trace) && trace.entityIndex !== undefined) { + damageByEntity.set(trace.entityIndex, (damageByEntity.get(trace.entityIndex) ?? 0) + profile.pelletDamage); + continue; + } + wallImpactTrace ??= trace; } let hit = false; for (const [entityIndex, damage] of damageByEntity) { - if (damageWeaponEntity(entityIndex, damage)) hit = true; + if (damageWeaponEntity(entityIndex, damage, { + direction: aim.direction, + fireKind: "hitscan", + weapon: profile.weapon, + })) hit = true; + } + if (wallImpactTrace) { + emitWeaponWallImpact(profile.weapon, "hitscan", "gunshot", aim.direction, wallImpactTrace); } return hit; } @@ -1100,7 +1139,12 @@ export function createQuakeWeaponsController({ const ray = weaponRayAtCrosshair(profile.range); const trace = traceWeaponRay(ray); if (!traceIsShootable(trace) || trace.entityIndex === undefined) return false; - return damageWeaponEntity(trace.entityIndex, profile.damage); + return damageWeaponEntity(trace.entityIndex, profile.damage, { + direction: ray.direction, + fireKind: "melee", + origin: trace.end, + weapon: profile.weapon, + }); } function fireBeam(profile: QuakeBeamFireProfile): boolean { @@ -1125,7 +1169,7 @@ export function createQuakeWeaponsController({ sourceEnd[1] + direction[1] * damageEndOffset, sourceEnd[2] + direction[2] * damageEndOffset, ]; - return damageBeamTraces(profile, damageOrigin, damageEnd); + return damageBeamTraces(profile, damageOrigin, damageEnd, direction); } function fireBeamUnderwaterDischarge(profile: QuakeBeamUnderwaterDischargeProfile): boolean { @@ -1283,9 +1327,19 @@ export function createQuakeWeaponsController({ let hit = false; const directEntityIndex = trace.entityIndex; + const wallImpactEffect = projectileWallImpactEffect(projectile.profile.weapon); + if (wallImpactEffect && !traceIsShootable(trace)) { + emitWeaponWallImpact(projectile.profile.weapon, "projectile", wallImpactEffect, projectile.direction, trace); + } if (projectile.damage > 0 && directEntityIndex !== undefined && traceIsShootable(trace) && damageWeaponEntity( directEntityIndex, projectileDamageForEntity(projectile.damage, projectile.profile, directEntityIndex), + { + direction: projectile.direction, + fireKind: "projectile", + origin: trace.end, + weapon: projectile.profile.weapon, + }, )) { hit = true; } @@ -1309,6 +1363,29 @@ export function createQuakeWeaponsController({ return "remove"; } + function emitWeaponWallImpact( + weapon: QuakeWeaponId, + fireKind: QuakeWeaponFireEvent["fireKind"], + effect: QuakeWeaponWallImpactEffect, + direction: Vec3, + trace: Pick, + ): void { + onWallImpact?.({ + direction: [...direction] as Vec3, + effect, + fireKind, + origin: [...trace.end] as Vec3, + targetKind: "world", + weapon, + }); + } + + function projectileWallImpactEffect(weapon: QuakeWeaponId): QuakeWeaponWallImpactEffect | null { + if (weapon === "nailgun") return "spike"; + if (weapon === "supernailgun") return "superspike"; + return null; + } + function handleProjectileExpire(projectile: QuakeWeaponProjectile): void { recordProjectileDebugEvent("expire", projectileDebugEventPayload(projectile)); if (!projectile.profile.explodeOnExpire) return; @@ -1490,7 +1567,7 @@ export function createQuakeWeaponsController({ ); } - function damageBeamTraces(profile: QuakeBeamFireProfile, start: Vec3, end: Vec3): boolean { + function damageBeamTraces(profile: QuakeBeamFireProfile, start: Vec3, end: Vec3, direction: Vec3): boolean { const offset = lightningDamageOffset(start, end, profile.damageTraceOffsetUnits); const offsets: Vec3[] = [ [0, 0, 0], @@ -1505,7 +1582,12 @@ export function createQuakeWeaponsController({ continue; } damagedEntityIndexes.add(trace.entityIndex); - if (damageWeaponEntity(trace.entityIndex, profile.damage)) hit = true; + if (damageWeaponEntity(trace.entityIndex, profile.damage, { + direction, + fireKind: "beam", + origin: trace.end, + weapon: profile.weapon, + })) hit = true; } return hit; } @@ -1531,11 +1613,29 @@ export function createQuakeWeaponsController({ return traceWeaponRay(viewRayFromDirection(origin, normalizeVec3(delta), range)); } - function damageWeaponEntity(entityIndex: number, amount: number): boolean { + function damageWeaponEntity( + entityIndex: number, + amount: number, + impact?: Omit & { + origin?: Vec3; + }, + ): boolean { const damageAmount = scaledWeaponDamage(amount); for (const shootable of getShootables()) { if (shootable.dead || shootable.entity.index !== entityIndex) continue; - return damageShootable(entityIndex, damageAmount); + const damaged = damageShootable(entityIndex, damageAmount); + if (damaged && impact) { + onDamageImpact?.({ + damage: damageAmount, + direction: [...impact.direction] as Vec3, + entityIndex, + fireKind: impact.fireKind, + origin: impact.origin ? [...impact.origin] as Vec3 : shootableImpactOrigin(shootable), + targetKind: "shootable", + weapon: impact.weapon, + }); + } + return damaged; } const entity = getEntities().get(entityIndex); if (!entity) return false; @@ -1545,6 +1645,15 @@ export function createQuakeWeaponsController({ return false; } + function shootableImpactOrigin(shootable: QuakeWeaponShootableTarget): Vec3 { + const { min, max } = shootable.bounds; + return [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ]; + } + function scaledWeaponDamage(amount: number): number { const multiplier = damageMultiplier?.() ?? 1; return amount * (Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1); diff --git a/src/runtime/world.ts b/src/runtime/world.ts index aa138f2..02857ef 100644 --- a/src/runtime/world.ts +++ b/src/runtime/world.ts @@ -46,6 +46,7 @@ const quakeMeshPresentationObservers = new WeakMap(); export interface QuakeFaceLeaf { + leafIndex: number; faceIndex: number; modelIndex?: number; entityIndex?: number; @@ -143,9 +144,11 @@ export interface QuakeWorldController { mount: (result: QuakeScene) => void; pixelate: (handle?: PolyMeshHandle | null) => void; schedulePresentationResync: (handle?: PolyMeshHandle | null) => Promise; + setDebugShellVisible: (visible: boolean) => void; syncVisibilityAt: (origin: [number, number, number], force?: boolean) => void; syncVisibility: (force?: boolean) => void; visibleLeavesAt: (origin: [number, number, number]) => Set | null; + waitForVisibleAtlasPages: () => Promise; } export interface QuakeWorldDebugBucket { @@ -201,6 +204,10 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) let semanticResidencyMetadataCache: QuakeSemanticResidencyMetadataCache | null = null; let semanticResidencyMetadataSource: QuakePreparedVisibilityMetadata | null = null; let semanticResidencyStats = createQuakeWorldSemanticResidencyStats(); + let visibleAtlasPageKey = ""; + let visibleAtlasPageSet = new Set(); + let visibleAtlasPageReadyPromise: Promise = Promise.resolve(); + let debugShellVisible = false; const clear = (): void => { cancelSemanticResidencyQueue(); @@ -224,6 +231,9 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) semanticResidencyMetadataCache = null; semanticResidencyMetadataSource = null; semanticResidencyStats = createQuakeWorldSemanticResidencyStats(); + visibleAtlasPageKey = ""; + visibleAtlasPageSet = new Set(); + visibleAtlasPageReadyPromise = Promise.resolve(); }; const clearPresentationResyncTimers = (): void => { @@ -304,6 +314,10 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) return; } options.syncPickupsVisibility(origin); + if (debugShellVisible) { + mountAllQuakeWorldLeaves(startedAt, force, "debug-shell"); + return; + } const nextLeafIndexValue = currentVisibility?.leafIndexAt(origin); const nextLeafIndex = Number.isInteger(nextLeafIndexValue) ? nextLeafIndexValue : null; syncWorldAtlasResidencyPages(nextLeafIndex); @@ -316,6 +330,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) let removedLeaves = 0; if (visibleFaceKey === "all") { visibleLeafIndex = nextLeafIndex; + syncMountedAtlasResidencyPages(); recordQuakeWorldVisibilitySync(visibilityChurn, "same-key", startedAt, { force }); if (force) { markQuakeTrace("world-visibility", { @@ -338,6 +353,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) visibleFaceKey = "all"; visibleLeafIndex = nextLeafIndex; } + syncMountedAtlasResidencyPages(); recordQuakeWorldVisibilitySync(visibilityChurn, "no-pvs", startedAt, { force, addedLeaves, removedLeaves }); if (force || addedLeaves > 0 || removedLeaves > 0) { markQuakeTrace("world-visibility", { @@ -372,6 +388,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) if (nextKey === visibleFaceKey) { const leafChanged = nextLeafIndex !== visibleLeafIndex; visibleLeafIndex = nextLeafIndex; + syncMountedAtlasResidencyPages(); recordQuakeWorldVisibilitySync(visibilityChurn, "same-key", startedAt, { force, pvsFaceCount: visibleFaces.size, @@ -423,6 +440,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) if (change > 0) addedLeaves++; if (change < 0) removedLeaves++; } + syncMountedAtlasResidencyPages(); const mutationJsMs = performance.now() - mutationStartedAt; const mountedLeafCountAfter = countMountedQuakeLeaves(); recordQuakeWorldVisibilitySync(visibilityChurn, force ? "force" : "leaf-change", startedAt, { @@ -498,6 +516,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) const queueResult = force ? applySemanticResidencyQueue(now, residencyOptions.budget) : emptyResidencyQueueApplyResult(); + syncMountedAtlasResidencyPages(); const mountedLeafCountAfter = countMountedQuakeLeaves(); if (semanticResidencyQueue.length > 0) scheduleSemanticResidencyQueue(residencyOptions); updateSemanticResidencyStats(residencyOptions, { @@ -580,6 +599,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } const queueResult = applySemanticResidencyQueue(now, residencyOptions.budget); + syncMountedAtlasResidencyPages(); const mutationJsMs = performance.now() - mutationStartedAt; if (semanticResidencyQueue.length > 0) scheduleSemanticResidencyQueue(residencyOptions); visibleFaceKey = nextKey; @@ -761,6 +781,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) removedLeaves: 0, }); if (queueResult.addedLeaves > 0 || semanticResidencyQueue.length > 0) { + syncMountedAtlasResidencyPages(); markQuakeTrace("world-visibility", { reason: "semantic-residency-queue", queuedAddedLeaves: queueResult.addedLeaves, @@ -968,6 +989,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) options.syncButtonLeafVisual(leaf); syncQuakeLightstyleLeafAnimationClock(leaf.element, leaf.lightstyleStyleId, now); syncQuakeTextureAnimationLeafAnimationClock(leaf.element, now); + exposeMountedLeafAtlasResidencyPage(leaf); insertQuakeLeafInOrder(leaf); leaf.element.hidden = false; } else { @@ -1034,12 +1056,73 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) const handle = mountQuakeRenderBundleMesh(options.sceneElement, renderBundle); currentRenderBundle = renderBundle; const element = handle.element; + element.classList.add("quake-world-mesh"); stripQuakeWorldMeshMetadata(element); faceLeaves = indexQuakeFaceLeaves(handle, renderBundle, new Map(), true, "world"); preloadQuakeButtonStateTextures(); return handle; }; + const setDebugShellVisible = (visible: boolean): void => { + if (debugShellVisible === visible) return; + debugShellVisible = visible; + if (visible) { + mountAllQuakeWorldLeaves(performance.now(), true, "debug-shell-enable"); + } + }; + + const mountAllQuakeWorldLeaves = (startedAt: number, force: boolean, reason: string): void => { + const mountedLeafCountBefore = countMountedQuakeLeaves(); + const prevLeafIndex = visibleLeafIndex; + const prevVisibleFaceKey = visibleFaceKey || null; + const now = performance.now(); + let addedLeaves = 0; + const mutationStartedAt = performance.now(); + for (const leaf of quakeLeaves) { + if (setQuakeLeafMounted(leaf, true, now) > 0) addedLeaves++; + } + syncMountedAtlasResidencyPages(); + visibleFaceKey = "debug-shell"; + visibleLeafIndex = null; + semanticResidencyDesiredLeaves = new Set(quakeLeaves); + semanticResidencyQueue = []; + const mutationJsMs = performance.now() - mutationStartedAt; + const mountedLeafCountAfter = countMountedQuakeLeaves(); + recordQuakeWorldVisibilitySync(visibilityChurn, force ? "force" : "same-key", startedAt, { + force, + addedLeaves, + removedLeaves: 0, + }); + recordResidencyTransition({ + addCount: addedLeaves, + force, + mountedLeafCountAfter, + mountedLeafCountBefore, + mutationJsMs, + nextLeafIndex: null, + nextVisibleFaceKey: "debug-shell", + prevLeafIndex, + prevVisibleFaceKey, + reason, + scannedFaceLeafCount: quakeLeaves.length, + startedAt, + visibleFaceCount: null, + removeCount: 0, + }); + if (force || addedLeaves > 0) { + markQuakeTrace("world-visibility", { + reason, + force, + addedLeaves, + removedLeaves: 0, + mountedLeaves: mountedLeafCountAfter, + mutationJsMs, + }); + } + }; + + const waitForVisibleAtlasPages = (): Promise => visibleAtlasPageReadyPromise; + const syncWorldAtlasResidencyPages = (leafIndex: number | null): void => { const atlasResidency = currentRenderBundle?.atlasResidency; if (!currentHandle || !currentRenderBundle || atlasResidency?.mode !== "pvs-pages") return; @@ -1052,10 +1135,61 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) : atlasResidency.visibilityLeafPrewarmPages[leafIndex] ?? currentPages; const exposedPages = currentPages.length ? currentPages : allPages; const warmPages = prewarmPages.length ? prewarmPages : exposedPages; - exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, exposedPages); + setVisibleAtlasResidencyPages(exposedPages); void preloadQuakeRenderBundleAtlasPages(currentRenderBundle, warmPages); }; + const setVisibleAtlasResidencyPages = (pageIndexes: readonly number[]): void => { + if (!currentHandle || !currentRenderBundle) return; + const nextPages = normalizedAtlasResidencyPageIndexes(pageIndexes); + exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, nextPages); + visibleAtlasPageSet = new Set(nextPages); + const pageKey = nextPages.join(","); + if (pageKey !== visibleAtlasPageKey) { + visibleAtlasPageKey = pageKey; + visibleAtlasPageReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages); + } + }; + + const syncMountedAtlasResidencyPages = (): void => { + if (!currentHandle || !currentRenderBundle || currentRenderBundle.atlasResidency?.mode !== "pvs-pages") return; + const nextPages = mountedAtlasResidencyPageIndexes(visibleAtlasPageSet); + const pageKey = nextPages.join(","); + if (pageKey === visibleAtlasPageKey) return; + exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, nextPages); + visibleAtlasPageSet = new Set(nextPages); + visibleAtlasPageKey = pageKey; + visibleAtlasPageReadyPromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, nextPages); + }; + + const normalizedAtlasResidencyPageIndexes = (pageIndexes: Iterable): number[] => + [...new Set([...pageIndexes].filter((pageIndex) => Number.isInteger(pageIndex) && pageIndex >= 0))] + .sort((a, b) => a - b); + + const mountedAtlasResidencyPageIndexes = (pageIndexes: Iterable): number[] => { + const pages = new Set(normalizedAtlasResidencyPageIndexes(pageIndexes)); + const leafPageIndexes = currentRenderBundle?.atlasResidency?.leafPageIndexes; + if (!leafPageIndexes) return normalizedAtlasResidencyPageIndexes(pages); + for (const leaf of quakeLeaves) { + if (leaf.meshKind !== "world" || !leaf.mounted || !leaf.element.isConnected) continue; + const pageIndex = leafPageIndexes[leaf.leafIndex]; + if (Number.isInteger(pageIndex) && pageIndex >= 0) pages.add(pageIndex); + } + return normalizedAtlasResidencyPageIndexes(pages); + }; + + const exposeMountedLeafAtlasResidencyPage = (leaf: QuakeFaceLeaf): void => { + if (leaf.meshKind !== "world" || !currentHandle || !currentRenderBundle) return; + const pageIndex = currentRenderBundle.atlasResidency?.leafPageIndexes[leaf.leafIndex]; + if (!Number.isInteger(pageIndex) || pageIndex < 0) return; + exposeQuakeRenderBundleAtlasPages(currentHandle.element, currentRenderBundle, [pageIndex]); + if (visibleAtlasPageSet.has(pageIndex)) return; + visibleAtlasPageSet.add(pageIndex); + visibleAtlasPageKey = [...visibleAtlasPageSet].sort((a, b) => a - b).join(","); + const pagePromise = preloadQuakeRenderBundleAtlasPages(currentRenderBundle, [pageIndex]); + visibleAtlasPageReadyPromise = Promise.all([visibleAtlasPageReadyPromise, pagePromise]).then(() => undefined); + }; + const addQuakeLightstyleRenderBundleMesh = (renderBundle: QuakePreparedRenderBundle): PolyMeshHandle => { const handle = mountQuakeRenderBundleMesh(options.sceneElement, renderBundle); handle.element.classList.add("quake-lightstyle-mesh"); @@ -1117,6 +1251,7 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) } const previous = previousByParent.get(parent); const record: QuakeFaceLeaf = { + leafIndex: index, faceIndex, ...(Number.isInteger(modelIndex) ? { modelIndex } : {}), ...(Number.isInteger(entityIndex) ? { entityIndex } : {}), @@ -1193,9 +1328,11 @@ export function createQuakeWorldController(options: QuakeWorldControllerOptions) mount, pixelate, schedulePresentationResync, + setDebugShellVisible, syncVisibilityAt, syncVisibility, visibleLeavesAt: (origin: [number, number, number]) => currentVisibility?.visibleLeavesAt(origin) ?? null, + waitForVisibleAtlasPages, }; } diff --git a/test/impactParticleFlow.test.mjs b/test/impactParticleFlow.test.mjs new file mode 100644 index 0000000..fc51946 --- /dev/null +++ b/test/impactParticleFlow.test.mjs @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Window } from "happy-dom"; + +import { importTsModule } from "./importTsModule.mjs"; + +const { + createQuakeImpactParticleFlow, +} = await importTsModule("src/runtime/app/impactParticleFlow.ts"); + +test("impact particles use a fixed b-quad pool and do not allocate during spawn", () => { + const previousCancelAnimationFrame = globalThis.cancelAnimationFrame; + const previousDocument = globalThis.document; + const previousRandom = Math.random; + const previousPerformance = globalThis.performance; + const previousRequestAnimationFrame = globalThis.requestAnimationFrame; + const previousWindow = globalThis.window; + const window = new Window(); + let now = 1000; + let nextFrameId = 1; + let createElementCalls = 0; + const frames = new Map(); + + Object.defineProperty(globalThis, "document", { + configurable: true, + value: window.document, + }); + Object.defineProperty(globalThis, "performance", { + configurable: true, + value: { now: () => now }, + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: window, + }); + Object.defineProperty(globalThis, "requestAnimationFrame", { + configurable: true, + value: (callback) => { + const frameId = nextFrameId++; + frames.set(frameId, callback); + return frameId; + }, + }); + Object.defineProperty(globalThis, "cancelAnimationFrame", { + configurable: true, + value: (frameId) => { frames.delete(frameId); }, + }); + + const originalCreateElement = document.createElement.bind(document); + document.createElement = (tagName, options) => { + createElementCalls += 1; + return originalCreateElement(tagName, options); + }; + Math.random = () => 0; + + try { + const layer = document.createElement("div"); + createElementCalls = 0; + const flow = createQuakeImpactParticleFlow({ + canShow: () => true, + isGameplayPaused: () => false, + layer, + now: () => now, + }); + + assert.equal(createElementCalls, 24); + assert.equal(layer.children.length, 24); + assert.deepEqual([...layer.children].map((element) => element.tagName), new Array(24).fill("B")); + + createElementCalls = 0; + flow.spawnBlood({ count: 10 }); + + assert.equal(createElementCalls, 0); + assert.equal([...layer.children].filter((element) => element.style.opacity === "1").length, 5); + assert.equal(frames.size, 1); + + flow.setEnabled(false); + + assert.equal(frames.size, 0); + assert.equal([...layer.children].every((element) => element.style.opacity === "0"), true); + + createElementCalls = 0; + flow.spawnBlood(); + assert.equal(createElementCalls, 0); + assert.equal(frames.size, 0); + + flow.setEnabled(true); + createElementCalls = 0; + flow.spawnWallImpact({ count: 10 }); + assert.equal(createElementCalls, 0); + assert.equal([...layer.children].filter((element) => element.style.opacity === "1").length, 4); + assert.equal([...layer.children].some((element) => element.className.includes("quake-impact-particle-dust-")), true); + flow.clear(); + + flow.dispose(); + assert.equal(layer.children.length, 0); + + const damageLayer = document.createElement("div"); + createElementCalls = 0; + const damageFlow = createQuakeImpactParticleFlow({ + canShow: () => true, + isGameplayPaused: () => false, + layer: damageLayer, + maxParticles: 5, + now: () => now, + }); + assert.equal(createElementCalls, 5); + + createElementCalls = 0; + Math.random = () => 0.99; + damageFlow.spawnBlood({ damage: 4 }); + assert.equal(activeParticleCount(damageLayer), 1); + assert.equal(createElementCalls, 0); + damageFlow.clear(); + damageFlow.spawnBlood({ damage: 9 }); + assert.equal(activeParticleCount(damageLayer), 2); + damageFlow.clear(); + + Math.random = () => 0; + damageFlow.spawnBlood({ damage: 9 }); + assert.equal(activeParticleCount(damageLayer), 3); + damageFlow.clear(); + damageFlow.spawnBlood({ damage: 18 }); + assert.equal(activeParticleCount(damageLayer), 4); + damageFlow.clear(); + damageFlow.spawnBlood({ damage: 100 }); + assert.equal(activeParticleCount(damageLayer), 5); + assert.equal(createElementCalls, 0); + damageFlow.dispose(); + + const directionLayer = document.createElement("div"); + const directionFlow = createQuakeImpactParticleFlow({ + canShow: () => true, + isGameplayPaused: () => false, + layer: directionLayer, + maxParticles: 1, + now: () => now, + viewRotation: () => ({ rotX: 90, rotY: 270 }), + }); + + Math.random = () => 0.5; + directionFlow.spawnBlood({ count: 1, directionHint: [1, 0, 0] }); + const directionOffset = particleOffset(directionLayer.children[0].style.transform); + assert.equal(directionOffset.x > 0, true); + assert.equal(Math.abs(directionOffset.y) < 0.001, true); + directionFlow.dispose(); + + const distanceLayer = document.createElement("div"); + const distanceFlow = createQuakeImpactParticleFlow({ + canShow: () => true, + isGameplayPaused: () => false, + layer: distanceLayer, + maxParticles: 1, + now: () => now, + viewOrigin: () => [0, 0, 0], + }); + + Math.random = () => 0; + distanceFlow.spawnBlood({ count: 1, origin: [0, 0, 0] }); + const nearScale = particleScale(distanceLayer.children[0].style.transform); + distanceFlow.clear(); + distanceFlow.spawnBlood({ count: 1, origin: [100, 0, 0] }); + const farScale = particleScale(distanceLayer.children[0].style.transform); + + assert.equal(nearScale, 2); + assert.equal(farScale, 0.58); + assert.equal(nearScale > farScale, true); + distanceFlow.dispose(); + } finally { + Math.random = previousRandom; + 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 (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, + }); + } + } +}); + +function particleScale(transform) { + const match = /scale\(([^,\s)]+)/.exec(transform); + return match ? Number(match[1]) : 0; +} + +function particleOffset(transform) { + const match = /translate3d\(([^p]+)px, ([^p]+)px, 0\)/.exec(transform); + return { + x: match ? Number(match[1]) : 0, + y: match ? Number(match[2]) : 0, + }; +} + +function activeParticleCount(layer) { + return [...layer.children].filter((element) => element.style.opacity === "1").length; +} diff --git a/test/playerFallDamage.test.mjs b/test/playerFallDamage.test.mjs new file mode 100644 index 0000000..febc1ac --- /dev/null +++ b/test/playerFallDamage.test.mjs @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "./importTsModule.mjs"; + +const constants = await importTsModule("src/runtime/constants.ts"); +const hazards = await importTsModule("src/runtime/hazards.ts"); +const physics = await importTsModule("src/runtime/playerPhysics.ts"); +const simulation = await importTsModule("src/runtime/multiplayer/simulation.ts"); + +const SCALE = constants.QUAKE_COLLISION_UNIT_SCALE; +const EYE_HEIGHT = (constants.QUAKE_PLAYER_VIEW_Z - constants.QUAKE_PLAYER_MINS_Z); + +test("fall damage follows Quake PlayerPostThink velocity thresholds", () => { + assert.equal(physics.quakePlayerFallDamageFromVelocityZ(-299 * SCALE), 0); + assert.equal(physics.quakePlayerFallDamageFromVelocityZ(-300 * SCALE), 0); + assert.equal(physics.quakePlayerFallDamageFromVelocityZ(-301 * SCALE), 0); + assert.equal(physics.quakePlayerFallDamageFromVelocityZ(-650 * SCALE), 0); + assert.equal(physics.quakePlayerFallDamageFromVelocityZ(-651 * SCALE), 5); +}); + +test("authoritative multiplayer simulation emits fall damage when landing fast", () => { + const player = createPlayer({ + origin: [0, 0, EYE_HEIGHT + 0.01], + velocity: [0, 0, -651 * SCALE], + }); + const state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ + playerId: player.playerId, + now: 0, + grounded: false, + floorZ: 0, + }); + + const result = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(player, state, { + now: 50, + tickMs: 50, + collisionWorld: createFlatCollisionWorld(), + playerEyeHeight: EYE_HEIGHT, + }); + + assert.deepEqual(result.hazardDamages.map(({ damage, kind, waterLevel }) => ({ damage, kind, waterLevel })), [ + { damage: 5, kind: "fall", waterLevel: 0 }, + ]); + assert.equal(result.state.grounded, true); + assert.equal(result.state.fallVelocityZ, undefined); +}); + +test("authoritative multiplayer fall damage is blocked by water", () => { + const player = createPlayer({ + origin: [0, 0, EYE_HEIGHT + 0.01], + velocity: [0, 0, -651 * SCALE], + }); + const state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ + playerId: player.playerId, + now: 0, + grounded: false, + floorZ: 0, + }); + + const result = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(player, state, { + now: 50, + tickMs: 50, + collisionWorld: createFlatCollisionWorld({ contents: hazards.QUAKE_CONTENTS_WATER }), + playerEyeHeight: EYE_HEIGHT, + }); + + assert.deepEqual(result.hazardDamages, []); + assert.equal(result.state.grounded, true); + assert.equal(result.state.fallVelocityZ, undefined); +}); + +test("authoritative multiplayer grounded descent does not emit fall damage", () => { + const player = createPlayer({ + origin: [0, 0, EYE_HEIGHT], + velocity: [320 * SCALE, 0, -900 * SCALE], + }); + const state = simulation.createQuakeMultiplayerRoomPlayerSimulationState({ + playerId: player.playerId, + now: 0, + grounded: true, + floorZ: 0, + }); + + const result = simulation.advanceQuakeMultiplayerRoomPlayerSimulation(player, state, { + now: 50, + tickMs: 50, + collisionWorld: createFlatCollisionWorld(), + playerEyeHeight: EYE_HEIGHT, + }); + + assert.deepEqual(result.hazardDamages, []); + assert.equal(result.state.grounded, true); + assert.equal(result.state.fallVelocityZ, undefined); +}); + +function createPlayer(overrides = {}) { + return { + playerId: "player-1", + clientId: "client-1", + displayName: "Player", + mapName: "e1m1", + origin: [0, 0, EYE_HEIGHT], + velocity: [0, 0, 0], + rotX: 0, + rotY: 0, + health: 100, + armor: 0, + activeWeapon: "shotgun", + alive: true, + inventory: { + health: 100, + armor: 0, + armorType: 0, + activeWeapon: "shotgun", + itemFlags: 0, + weapons: ["axe", "shotgun"], + shells: 25, + nails: 0, + rockets: 0, + cells: 0, + keys: [], + powerups: [], + }, + lastInputSequence: 0, + updatedAt: 0, + ...overrides, + }; +} + +function createFlatCollisionWorld(options = {}) { + return { + contentsAt: () => options.contents ?? null, + floorAt: () => 0, + resolve: (target) => { + if (target[2] <= EYE_HEIGHT) { + return { + origin: [target[0], target[1], EYE_HEIGHT], + groundZ: 0, + grounded: true, + }; + } + return { + origin: target, + groundZ: 0, + grounded: false, + }; + }, + }; +} diff --git a/test/shootablePrewarm.test.mjs b/test/shootablePrewarm.test.mjs new file mode 100644 index 0000000..769e2b8 --- /dev/null +++ b/test/shootablePrewarm.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "./importTsModule.mjs"; + +const { + createQuakeShootablePrewarmQueues, +} = await importTsModule("src/runtime/shootables/prewarm.ts"); + +test("timed-out shootable prewarm drain mounts the selected small batch", () => { + const previousWindow = globalThis.window; + const idleCallbacks = []; + globalThis.window = { + requestIdleCallback(callback) { + idleCallbacks.push(callback); + return idleCallbacks.length; + }, + cancelIdleCallback() {}, + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }; + + try { + const states = new Map([ + [1, shootableState(1)], + [2, shootableState(2)], + [3, shootableState(3)], + ]); + const mounted = []; + const queues = createQuakeShootablePrewarmQueues({ + canPoolAnimationFrame: () => false, + canPrewarmShootable: () => true, + ensureAnimationFrame: () => undefined, + getShootable: (entityIndex) => states.get(entityIndex), + mountShootable: (shootable) => { + shootable.handle = {}; + mounted.push(shootable.entity.index); + }, + setShootableVisible: (shootable, visible) => { + shootable.visible = visible; + }, + timeoutMs: 250, + trimAnimationFrameHandles: () => undefined, + }); + + queues.setDesiredPrewarmIndexes(new Set([1, 2, 3])); + for (const shootable of states.values()) queues.scheduleShootable(shootable); + + assert.equal(idleCallbacks.length, 1); + idleCallbacks[0]({ didTimeout: true, timeRemaining: () => 0 }); + + assert.deepEqual(mounted, [1, 2, 3]); + assert.equal(queues.prewarmQueueLength(), 0); + for (const shootable of states.values()) { + assert.equal(shootable.handle !== null, true); + assert.equal(shootable.visible, false); + } + } finally { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + } +}); + +test("prewarm drain keeps one-mesh minimum when idle time is exhausted", () => { + const previousWindow = globalThis.window; + const idleCallbacks = []; + globalThis.window = { + requestIdleCallback(callback) { + idleCallbacks.push(callback); + return idleCallbacks.length; + }, + cancelIdleCallback() {}, + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + }; + + try { + const states = new Map([ + [1, shootableState(1)], + [2, shootableState(2)], + ]); + const mounted = []; + const queues = createQuakeShootablePrewarmQueues({ + canPoolAnimationFrame: () => false, + canPrewarmShootable: () => true, + ensureAnimationFrame: () => undefined, + getShootable: (entityIndex) => states.get(entityIndex), + mountShootable: (shootable) => { + shootable.handle = {}; + mounted.push(shootable.entity.index); + }, + setShootableVisible: (shootable, visible) => { + shootable.visible = visible; + }, + timeoutMs: 250, + trimAnimationFrameHandles: () => undefined, + }); + + queues.setDesiredPrewarmIndexes(new Set([1, 2])); + for (const shootable of states.values()) queues.scheduleShootable(shootable); + + idleCallbacks[0]({ didTimeout: false, timeRemaining: () => 0 }); + + assert.deepEqual(mounted, [1]); + assert.equal(queues.prewarmQueueLength(), 1); + } finally { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + } +}); + +function shootableState(entityIndex) { + return { + dead: false, + entity: { index: entityIndex }, + frameHandles: new Map(), + handle: null, + visible: false, + }; +} diff --git a/test/weaponImpactParticles.test.mjs b/test/weaponImpactParticles.test.mjs new file mode 100644 index 0000000..243b886 --- /dev/null +++ b/test/weaponImpactParticles.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "./importTsModule.mjs"; + +const { + createQuakeWeaponsController, +} = await importTsModule("src/runtime/weapons.ts"); + +function createShootable(index, origin = [0, 0, 0]) { + return { + bounds: { + min: [origin[0] - 0.5, origin[1] - 0.5, origin[2] - 0.5], + max: [origin[0] + 0.5, origin[1] + 0.5, origin[2] + 0.5], + }, + dead: false, + entity: { + classname: "monster_grunt", + index, + properties: { classname: "monster_grunt" }, + }, + origin, + }; +} + +function createWeaponsHarness({ activeWeapon = "rocketlauncher", collisionWorld = null, shootables }) { + const damageCalls = []; + const impacts = []; + const wallImpacts = []; + let hits = 0; + const entities = new Map(shootables.map((shootable) => [shootable.entity.index, shootable.entity])); + const weapons = createQuakeWeaponsController({ + addProjectileMesh: () => null, + canUseGameplayInput: () => true, + consumeAmmo: () => undefined, + controls: { + getOrigin: () => [100, 100, 100], + }, + damageBrushEntity: () => true, + damageMultiplier: () => 1, + damagePlayer: () => false, + damageShootable: (entityIndex, amount) => { + damageCalls.push({ amount, entityIndex }); + return true; + }, + getActiveWeapon: () => activeWeapon, + getAmmo: () => 999, + getCollisionWorld: () => collisionWorld, + getEntities: () => entities, + getPlayerEyeHeight: () => 1.7, + getPlayerWaterLevel: () => 0, + getShootables: () => shootables, + hasViewmodel: () => true, + onDamageImpact: (event) => { impacts.push(event); }, + onHit: () => { hits += 1; }, + onWallImpact: (event) => { wallImpacts.push(event); }, + playFireAnimation: () => undefined, + playFireSound: () => undefined, + random: () => 0, + scene: { + camera: { + state: { + rotX: 90, + rotY: 270, + }, + }, + }, + selectBestWeapon: () => "axe", + syncCrosshairTarget: () => undefined, + syncHud: () => undefined, + }); + return { damageCalls, impacts, hits: () => hits, wallImpacts, weapons }; +} + +test("projectile direct shootable damage emits one damage-impact event", () => { + const { damageCalls, impacts, hits, weapons } = createWeaponsHarness({ + shootables: [createShootable(1)], + }); + + const result = weapons.debugProjectileImpact("nailgun", 1, [0, 0, 0], 9); + + assert.equal(result?.impactResult, "remove"); + assert.equal(hits(), 1); + assert.deepEqual(damageCalls.map((call) => call.entityIndex), [1]); + assert.equal(impacts.length, 1); + assert.equal(impacts[0].damage, 9); + assert.deepEqual(impacts[0].direction, [0, -1, 0]); + assert.equal(impacts[0].entityIndex, 1); + assert.equal(impacts[0].fireKind, "projectile"); + assert.deepEqual(impacts[0].origin, [0, 0, 0]); + assert.equal(impacts[0].targetKind, "shootable"); + assert.equal(impacts[0].weapon, "nailgun"); +}); + +test("projectile world impact emits one spike wall-impact event", () => { + const { damageCalls, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ + shootables: [], + }); + + const result = weapons.debugProjectileImpact("nailgun", null, [0, 0, 0], 9); + + assert.equal(result?.impactResult, "remove"); + assert.equal(result?.directEntityIndex, null); + assert.equal(hits(), 0); + assert.equal(damageCalls.length, 0); + assert.equal(impacts.length, 0); + assert.equal(wallImpacts.length, 1); + assert.deepEqual(wallImpacts[0].direction, [0, -1, 0]); + assert.equal(wallImpacts[0].effect, "spike"); + assert.equal(wallImpacts[0].fireKind, "projectile"); + assert.deepEqual(wallImpacts[0].origin, [0, 0, 0]); + assert.equal(wallImpacts[0].targetKind, "world"); + assert.equal(wallImpacts[0].weapon, "nailgun"); +}); + +test("hitscan wall traces emit one aggregated gunshot wall-impact event", () => { + const wallTrace = { + end: [1, 2, 3], + fraction: 0.25, + planeNormal: [0, 1, 0], + }; + const { impacts, wallImpacts, weapons } = createWeaponsHarness({ + activeWeapon: "shotgun", + collisionWorld: { + traceUse: () => wallTrace, + }, + shootables: [], + }); + + assert.equal(weapons.fire(1000), true); + + assert.equal(impacts.length, 0); + assert.equal(wallImpacts.length, 1); + assert.equal(wallImpacts[0].effect, "gunshot"); + assert.equal(wallImpacts[0].fireKind, "hitscan"); + assert.deepEqual(wallImpacts[0].origin, [1, 2, 3]); + assert.equal(wallImpacts[0].targetKind, "world"); + assert.equal(wallImpacts[0].weapon, "shotgun"); +}); + +test("projectile splash-only damage does not emit damage-impact events", () => { + const { damageCalls, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ + shootables: [ + createShootable(1, [0, 0, 0]), + createShootable(2, [1, 0, 0]), + ], + }); + + const result = weapons.debugProjectileImpact("rocketlauncher", 1, [0, 0, 0], 0); + + assert.equal(result?.impactResult, "remove"); + assert.equal(hits(), 1); + assert.equal(damageCalls.length > 0, true); + assert.equal(impacts.length, 0); + assert.equal(wallImpacts.length, 0); +});