diff --git a/src/App.ts b/src/App.ts index 218ed8a..8a024e7 100644 --- a/src/App.ts +++ b/src/App.ts @@ -42,6 +42,7 @@ import { markQuakeTrace } from "./runtime/debug/traceMarks"; import { shouldSpawnQuakeEntityForCurrentGame, shouldSpawnQuakeEntityForGameMode } from "./runtime/entities"; import { applyQuakeInventoryDelta, + changeQuakeInventoryWeaponByImpulse, createQuakeHudElements, selectQuakeBestInventoryWeapon, type QuakeKey, @@ -140,6 +141,7 @@ import { fetchQuakeScene, type QuakeAssetManifest, type QuakeMapLoadOptions, + type QuakeSceneMode, } from "./runtime/app/session"; import { createQuakePointHazardFlow } from "./runtime/app/pointHazardFlow"; import { @@ -223,6 +225,7 @@ import { quakeWeaponProjectileModelPath, type QuakeWeaponFireEvent, type QuakeWeaponFireSoundId, + type QuakeWeaponShootableTarget, type QuakeWeaponWallImpactEffect, type QuakeWeaponProjectileVisualHandle, } from "./runtime/weapons"; @@ -910,14 +913,26 @@ function currentQuakeCssView(): QuakeCssView { function shouldSpawnQuakeShootableForCurrentMode(entity: QuakeEntity): boolean { if (QUAKE_MULTIPLAYER_ENABLED && entity.classname.startsWith("monster_")) return false; - return shouldSpawnQuakeEntityForCurrentGame(entity); + return shouldSpawnQuakeEntityForCurrentMode(entity); } function shouldSpawnQuakePickupForCurrentMode(entity: QuakeEntity): boolean { + return shouldSpawnQuakeEntityForCurrentMode(entity); +} + +function shouldSpawnQuakeEntityForCurrentMode(entity: QuakeEntity): boolean { if (QUAKE_MULTIPLAYER_ENABLED) return shouldSpawnQuakeEntityForGameMode(entity, { deathmatch: true }); return shouldSpawnQuakeEntityForCurrentGame(entity); } +function quakeSceneModeForCurrentMode(): QuakeSceneMode { + return QUAKE_MULTIPLAYER_ENABLED ? "deathmatch" : "singleplayer"; +} + +function quakeSceneUrlForCurrentMode(mapName: string): string | undefined { + return quakeAssetCatalog.sceneUrl(mapName, quakeSceneModeForCurrentMode()); +} + function currentQuakeViewUrl(): string { return quakeRoute.currentViewUrl(); } @@ -1385,6 +1400,7 @@ let quakePlayerLifecycle!: QuakePlayerLifecycleFlow; let quakePointerGameplay!: ReturnType; const quakeGameplayInput = createQuakeGameplayInputFlow({ canUseGameplayInput: canUseQuakeGameplayInput, + changeWeaponByImpulse: changeQuakePlayerWeaponByImpulse, clearMobileLookInput: () => quakePointerGameplay.clearMobileLookInput(), clearMobileMoveInput: () => quakePointerGameplay.clearMobileMoveInput(), debugFlyEnabled: () => quakeDebugFly.isEnabled(), @@ -1955,6 +1971,7 @@ const weapons = createQuakeWeaponsController({ hasViewmodel: viewmodel.hasWeapon, getCollisionWorld: () => currentCollisionWorld, getEntities: () => entityByIndex, + getDamageableBrushTargets: quakeDamageableBrushWeaponTargets, getShootables: shootables.weaponTargets, getPlayerEyeHeight: () => getPlayer().eyeHeight(), getPlayerWaterLevel: () => @@ -2161,6 +2178,38 @@ let quakeMultiplayerLastReconciledInputSequence = 0; let quakeMultiplayerLastInventoryFingerprint: string | null = null; let quakeMultiplayerApplyingWorldEvent = false; const quakeMultiplayerPickupRequestAt = new Map(); + +function* quakeDamageableBrushWeaponTargets(): Iterable { + const sceneResult = currentResult; + if (!sceneResult) return; + for (const entry of quakeDamageableBrushes.snapshot().brushes) { + if (entry.health <= 0) continue; + const entity = entityByIndex.get(entry.entityIndex); + if (!entity || entity.classname !== "func_button" || entity.modelIndex === undefined) continue; + const model = sceneResult.models.find((item) => item.index === entity.modelIndex); + if (!model) continue; + const min: Vec3 = [ + (model.mins.x - quakeModelPivot.x) * QUAKE_COLLISION_UNIT_SCALE, + (model.mins.y - quakeModelPivot.y) * QUAKE_COLLISION_UNIT_SCALE, + (model.mins.z - quakeModelPivot.z) * QUAKE_COLLISION_UNIT_SCALE, + ]; + const max: Vec3 = [ + (model.maxs.x - quakeModelPivot.x) * QUAKE_COLLISION_UNIT_SCALE, + (model.maxs.y - quakeModelPivot.y) * QUAKE_COLLISION_UNIT_SCALE, + (model.maxs.z - quakeModelPivot.z) * QUAKE_COLLISION_UNIT_SCALE, + ]; + yield { + entity, + dead: false, + origin: [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ], + bounds: { min, max }, + }; + } +} const quakeMultiplayerWorldRequestAt = new Map(); let quakeMultiplayerLastRoomEvent: Record | null = null; let quakeMultiplayerRecentRoomEvents: Record[] = []; @@ -2436,6 +2485,22 @@ function syncQuakeHud(): void { quakeHudFlow.sync(); } +function changeQuakePlayerWeaponByImpulse(impulse: number): boolean { + if (!player || !canUseQuakeGameplayInput()) return false; + const result = changeQuakeInventoryWeaponByImpulse(player.inventory(), impulse); + if (!result) return false; + if (result.message) { + quakeTextPresentation.notify(result.message); + return true; + } + if (result.changed) { + syncQuakeHud(); + viewmodel.syncTransform(); + syncQuakeCrosshairTarget(); + } + return true; +} + function flashQuakeBonusOverlay(): void { quakeHudFlow.flashBonusOverlay(); } @@ -3443,7 +3508,7 @@ function quakeMapLoadView(options: QuakeMapLoadOptions): QuakeCssView | null { function currentQuakeMultiplayerRoomKey(): QuakeMultiplayerRoomCompatibilityKey | null { if (!currentResult) return null; - const sceneUrl = quakeAssetCatalog.sceneUrl(currentMapName); + const sceneUrl = quakeSceneUrlForCurrentMode(currentMapName); if (!sceneUrl) return null; return { mapName: currentMapName, @@ -5304,7 +5369,7 @@ async function loadQuake(): Promise { routeFromLocation: quakeUrlRouteFromLocation, routeIsDirect: quakeUrlRouteIsDirect, routeShouldNormalize: quakeUrlRouteShouldNormalize, - sceneUrl: quakeAssetCatalog.sceneUrl, + sceneUrl: quakeSceneUrlForCurrentMode, setAssetManifest: setQuakeAssetManifest, setCurrentMapName: (mapName) => { currentMapName = mapName; @@ -5461,7 +5526,7 @@ const quakeMapLoader = createQuakeAppMapLoader quakeViewmodelAssets.preload(progress), resumeGameplayAfterMapLoad: resumeQuakeGameplayAfterMapLoad, - sceneUrl: quakeAssetCatalog.sceneUrl, + sceneUrl: quakeSceneUrlForCurrentMode, setGameplayStarted: setQuakeGameplayStarted, setLoading: setQuakeLoading, syncUrlView: applyQuakeUrlView, @@ -5483,6 +5548,7 @@ const quakeInput = createQuakeAppInputController({ handleDebugFlyKey: quakeDebugFly.handleKey, handleMenuKeyDown: (event) => menu.handleKeyDown(event), handleMoveKey: quakeGameplayInput.handleMoveKey, + handleWeaponKey: quakeGameplayInput.handleWeaponKey, hidePersistedLoadingConsole: hidePersistedQuakeLoadingConsole, isEditableTarget: quakeGameplayInput.isEditableTarget, isLoading: () => quakeAppLoading, diff --git a/src/prepare/assets.mjs b/src/prepare/assets.mjs index bd6c387..5cd8510 100644 --- a/src/prepare/assets.mjs +++ b/src/prepare/assets.mjs @@ -1,7 +1,7 @@ import { build } from "esbuild"; import { execSync, spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { access, chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { availableParallelism, tmpdir } from "node:os"; import path from "node:path"; @@ -18,6 +18,11 @@ import { replaceQuakeRenderBundleWorldAtlas, } from "./deterministicAtlas.mjs"; import { prepareQuakeEffectSprites } from "./effectSprites.mjs"; +import { + QUAKE_PREPARED_SCENE_MODES, + quakePreparedSceneModeOutputPath, + quakePreparedSceneVariant, +} from "./sceneVariants.mjs"; import { applyQuakeWorldPlanarComponents } from "./worldPlanarComponents.mjs"; import { QUAKE_UNIT_SCALE } from "../quakeScale.js"; @@ -831,23 +836,44 @@ try { for (const item of preparedMaps) { const { outputPath, prepared } = item; const mapName = item.mapName ?? mapNameFromPakPath(item.mapPath); - const preparedJson = await runPrepareDetailStep(`map ${mapName} json stringify`, () => JSON.stringify(prepared)); - await runPrepareDetailStep(`map ${mapName} json mkdir`, () => mkdir(path.dirname(outputPath), { recursive: true })); - await runPrepareDetailStep(`map ${mapName} json write`, () => writeFile(outputPath, preparedJson)); - item.size = Buffer.byteLength(preparedJson); + item.modeOutputPaths = {}; + item.modeSizes = {}; + for (const mode of QUAKE_PREPARED_SCENE_MODES) { + const modeOutputPath = quakePreparedSceneModeOutputPath(outputPath, mode); + const modePrepared = await runPrepareDetailStep(`map ${mapName} ${mode} scene variant`, () => + quakePreparedSceneVariant(prepared, mode) + ); + const preparedJson = await runPrepareDetailStep(`map ${mapName} ${mode} json stringify`, () => + JSON.stringify(modePrepared) + ); + await runPrepareDetailStep(`map ${mapName} ${mode} json mkdir`, () => + mkdir(path.dirname(modeOutputPath), { recursive: true }) + ); + await runPrepareDetailStep(`map ${mapName} ${mode} json write`, () => writeFile(modeOutputPath, preparedJson)); + item.modeOutputPaths[mode] = modeOutputPath; + item.modeSizes[mode] = Buffer.byteLength(preparedJson); + } + item.size = item.modeSizes.singleplayer; } if (quakePrepareMapOnly) { await pruneUnreferencedTextureFiles( - preparedMaps.map((item) => item.outputPath), + quakePreparedMapOutputPaths(preparedMaps), { removeUnreferenced: false }, ); await writeFileAtomic( manifestOutputPath, - stableManifestJson ?? JSON.stringify(buildQuakeAssetManifest(preparedMaps, {}, { models: {} })), + stableManifestJson + ? quakeMapOnlyManifestJsonWithSceneVariants(stableManifestJson, preparedMaps) + : JSON.stringify(buildQuakeAssetManifest(preparedMaps, {}, { models: {} })), ); - for (const { outputPath, prepared, size } of preparedMaps) { - console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(size)})`); + for (const { modeOutputPaths, modeSizes, prepared, size } of preparedMaps) { + for (const mode of QUAKE_PREPARED_SCENE_MODES) { + const outputPath = modeOutputPaths?.[mode]; + const modeSize = modeSizes?.[mode]; + if (!outputPath || !modeSize) continue; + console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(modeSize)})`); + } console.log(`${prepared.label}: ${prepared.faceCount}/${prepared.sourceFaceCount} faces, ${prepared.textureCount} textures`); } console.log( @@ -951,12 +977,17 @@ try { effectSprites, ))); await pruneUnreferencedTextureFiles([ - ...preparedMaps.map((item) => item.outputPath), + ...quakePreparedMapOutputPaths(preparedMaps), ...weaponModelOutputPaths, pickupOutputPath, ]); - for (const { outputPath, prepared, size } of preparedMaps) { - console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(size)})`); + for (const { modeOutputPaths, modeSizes, prepared } of preparedMaps) { + for (const mode of QUAKE_PREPARED_SCENE_MODES) { + const outputPath = modeOutputPaths?.[mode]; + const size = modeSizes?.[mode]; + if (!outputPath || !size) continue; + console.log(`Wrote ${path.relative(projectRoot, outputPath)} (${formatBytes(size)})`); + } console.log(`${prepared.label}: ${prepared.faceCount}/${prepared.sourceFaceCount} faces, ${prepared.textureCount} textures`); } console.log(`Wrote ${path.relative(projectRoot, hudBaseOutputPath)}`); @@ -2381,7 +2412,7 @@ function buildQuakeAssetManifest( ) { const preparedModelPaths = new Set(Object.keys(pickupModels?.models ?? {})); const preparedSoundPaths = new Set(Object.keys(soundManifest?.sounds ?? {})); - const maps = preparedMaps.map(({ mapName, mapPath, outputPath, prepared }) => { + const maps = preparedMaps.map(({ mapName, mapPath, outputPath, prepared, modeOutputPaths }) => { const hasGameLogicFacts = Boolean(prepared?.gameLogic); const gameLogicModelPaths = quakeGameLogicMapModelPaths(prepared, preparedModelPaths); const gameLogicSoundPaths = quakeGameLogicMapSoundPaths(prepared, preparedSoundPaths); @@ -2393,11 +2424,16 @@ function buildQuakeAssetManifest( if (!modelPaths.includes(playerModelPath)) modelPaths.push(playerModelPath); } modelPaths.sort(); + const sceneUrls = Object.fromEntries( + Object.entries(modeOutputPaths ?? { singleplayer: outputPath }) + .map(([mode, modeOutputPath]) => [mode, generatedPublicUrl(modeOutputPath)]), + ); return { mapName, title: quakeMapTitles.get(mapName) ?? mapName.toUpperCase(), pakPath: mapPath, - sceneUrl: generatedPublicUrl(outputPath), + sceneUrl: sceneUrls.singleplayer ?? generatedPublicUrl(outputPath), + ...(sceneUrls.deathmatch ? { sceneUrls } : {}), selectable: quakeSelectableMapNames.has(mapName), modelPaths, ...(gameLogicSoundPaths.length ? { soundPaths: gameLogicSoundPaths } : {}), @@ -2421,6 +2457,41 @@ function buildQuakeAssetManifest( }; } +function quakePreparedMapOutputPaths(preparedMaps) { + return preparedMaps.flatMap((item) => + item.modeOutputPaths + ? Object.values(item.modeOutputPaths) + : [item.outputPath] + ); +} + +function quakeMapOnlyManifestJsonWithSceneVariants(stableManifestJson, preparedMaps) { + const manifest = JSON.parse(stableManifestJson); + const mapsByName = new Map(preparedMaps.map((item) => [item.mapName, item])); + manifest.maps = manifest.maps.map((map) => { + const item = mapsByName.get(map.mapName); + if (!item) return map; + const sceneUrls = Object.fromEntries( + Object.entries(item.modeOutputPaths ?? { singleplayer: item.outputPath }) + .map(([mode, outputPath]) => [mode, generatedPublicUrl(outputPath)]), + ); + return { + ...map, + sceneUrl: sceneUrls.singleplayer ?? map.sceneUrl, + ...(sceneUrls.deathmatch + ? { sceneUrls: { ...quakeManifestMapSceneUrls(map), ...sceneUrls } } + : {}), + }; + }); + return JSON.stringify(manifest); +} + +function quakeManifestMapSceneUrls(map) { + return map?.sceneUrls && typeof map.sceneUrls === "object" && !Array.isArray(map.sceneUrls) + ? map.sceneUrls + : {}; +} + function quakeMultiplayerPlayerModelPathsFromPreparedModels(preparedModelPaths) { return QUAKE_MULTIPLAYER_PLAYER_ALIAS_MODEL_PATHS .filter((modelPath) => preparedModelPaths.has(modelPath)); @@ -2436,10 +2507,16 @@ async function writeQuakeAssetManifestFromGeneratedFiles() { const sourceProgramFacts = await loadQuakeSourceProgramFacts(); const preparedMaps = []; for (const [mapPath, outputPath] of mapOutputPaths) { + const modeOutputPaths = { singleplayer: outputPath }; + const deathmatchOutputPath = quakePreparedSceneModeOutputPath(outputPath, "deathmatch"); + if (await generatedFileExists(deathmatchOutputPath)) { + modeOutputPaths.deathmatch = deathmatchOutputPath; + } preparedMaps.push({ mapName: mapNameFromPakPath(mapPath), mapPath, outputPath, + modeOutputPaths, prepared: await readQuakeGeneratedJson(outputPath, `${mapNameFromPakPath(mapPath)} map`), }); } @@ -2463,6 +2540,16 @@ async function readOptionalQuakeGeneratedJson(outputPath, label) { } } +async function generatedFileExists(outputPath) { + try { + await access(outputPath); + return true; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + async function readQuakeGeneratedJson(outputPath, label) { try { return JSON.parse(await readFile(outputPath, "utf8")); diff --git a/src/prepare/sceneVariants.mjs b/src/prepare/sceneVariants.mjs new file mode 100644 index 0000000..67a68f3 --- /dev/null +++ b/src/prepare/sceneVariants.mjs @@ -0,0 +1,276 @@ +const QUAKE_SPAWNFLAG_NOT_EASY = 256; +const QUAKE_SPAWNFLAG_NOT_MEDIUM = 512; +const QUAKE_SPAWNFLAG_NOT_HARD = 1024; +const QUAKE_SPAWNFLAG_NOT_DEATHMATCH = 2048; +const QUAKE_SINGLE_PLAYER_SKILL = 0; + +export const QUAKE_PREPARED_SCENE_MODES = ["singleplayer", "deathmatch"]; + +export function quakePreparedSceneModeOutputPath(outputPath, mode) { + if (mode === "singleplayer") return outputPath; + if (mode === "deathmatch") return outputPath.replace(/\.json$/i, ".deathmatch.json"); + throw new Error(`Unsupported Quake prepared scene mode ${String(mode)}.`); +} + +export function quakePreparedSceneVariant(prepared, mode) { + const activeEntityIndexes = quakePreparedSceneActiveEntityIndexes(prepared, mode); + const variant = cloneJson(prepared); + variant.entities = (variant.entities ?? []).filter((entity) => activeEntityIndexes.has(entity.index)); + if (variant.entityManifest) { + variant.entityManifest = quakeEntityManifestVariant(variant.entityManifest, activeEntityIndexes); + } + if (variant.gameLogic) { + variant.gameLogic = quakeGameLogicVariant(variant.gameLogic, activeEntityIndexes); + } + if (variant.collision) { + variant.collision = quakePreparedCollisionVariant(variant.collision, activeEntityIndexes); + } + if (variant.visibility?.brushModels) { + variant.visibility.brushModels = variant.visibility.brushModels + .filter((brushModel) => + brushModel.entityIndex === undefined || activeEntityIndexes.has(brushModel.entityIndex) + ); + } + if (variant.renderBundle) { + variant.renderBundle = quakePreparedRenderBundleVariant(variant.renderBundle, activeEntityIndexes); + } + if (variant.lightstyleRenderBundle) { + variant.lightstyleRenderBundle = quakePreparedRenderBundleVariant(variant.lightstyleRenderBundle, activeEntityIndexes); + } + return variant; +} + +export function quakePreparedSceneActiveEntityIndexes(prepared, mode) { + return new Set( + (prepared.entities ?? []) + .filter((entity) => quakePreparedSceneEntityActive(entity, mode)) + .map((entity) => entity.index), + ); +} + +export function quakePreparedSceneEntityActive(entity, mode) { + if (mode === "singleplayer") { + if (entity.classname === "info_player_deathmatch" || entity.classname === "info_player_coop") return false; + const spawnflags = quakeEntitySpawnflags(entity); + if (QUAKE_SINGLE_PLAYER_SKILL <= 0 && (spawnflags & QUAKE_SPAWNFLAG_NOT_EASY)) return false; + if (QUAKE_SINGLE_PLAYER_SKILL === 1 && (spawnflags & QUAKE_SPAWNFLAG_NOT_MEDIUM)) return false; + if (QUAKE_SINGLE_PLAYER_SKILL >= 2 && (spawnflags & QUAKE_SPAWNFLAG_NOT_HARD)) return false; + return true; + } + if (mode === "deathmatch") { + if (entity.classname === "info_player_coop") return false; + return (quakeEntitySpawnflags(entity) & QUAKE_SPAWNFLAG_NOT_DEATHMATCH) === 0; + } + throw new Error(`Unsupported Quake prepared scene mode ${String(mode)}.`); +} + +function quakeEntityManifestVariant(manifest, activeEntityIndexes) { + const entries = (manifest.entries ?? []).filter((entry) => activeEntityIndexes.has(entry.entityIndex)); + const filterEntityIndexArray = (items = []) => items.filter((item) => activeEntityIndexes.has(item.entityIndex)); + const runtime = manifest.runtime + ? quakeEntityRuntimeManifestVariant(manifest.runtime, activeEntityIndexes) + : manifest.runtime; + return { + ...manifest, + totals: quakeEntityManifestTotals(entries), + entries, + starts: filterEntityIndexArray(manifest.starts), + pickups: filterEntityIndexArray(manifest.pickups), + monsters: filterEntityIndexArray(manifest.monsters), + triggers: filterEntityIndexArray(manifest.triggers), + movers: filterEntityIndexArray(manifest.movers), + teleporters: filterEntityIndexArray(manifest.teleporters) + .map((teleporter) => ({ + ...teleporter, + destinationEntityIndexes: filterIndexes(teleporter.destinationEntityIndexes, activeEntityIndexes), + })), + exits: filterEntityIndexArray(manifest.exits), + ...(manifest.intermissions ? { intermissions: filterEntityIndexArray(manifest.intermissions) } : {}), + lights: filterEntityIndexArray(manifest.lights), + counters: filterEntityIndexArray(manifest.counters), + secrets: filterEntityIndexArray(manifest.secrets), + inert: filterEntityIndexArray(manifest.inert), + ...(runtime ? { runtime } : {}), + }; +} + +function quakeEntityManifestTotals(entries) { + const byClassname = {}; + const byCategory = {}; + let active = 0; + let metadataOnly = 0; + let ignored = 0; + for (const entry of entries) { + byClassname[entry.classname] = (byClassname[entry.classname] ?? 0) + 1; + byCategory[entry.category] = (byCategory[entry.category] ?? 0) + 1; + if (entry.runtimeStatus === "active") active++; + if (entry.runtimeStatus === "metadata-only") metadataOnly++; + if (entry.runtimeStatus === "ignored") ignored++; + } + return { + entities: entries.length, + active, + metadataOnly, + ignored, + byClassname, + byCategory, + }; +} + +function quakeEntityRuntimeManifestVariant(runtime, activeEntityIndexes) { + return { + ...runtime, + targetEntities: Object.fromEntries( + Object.entries(runtime.targetEntities ?? {}) + .map(([targetname, indexes]) => [targetname, filterIndexes(indexes, activeEntityIndexes)]) + .filter(([, indexes]) => indexes.length > 0), + ), + triggerCounterCounts: (runtime.triggerCounterCounts ?? []) + .filter(([entityIndex]) => activeEntityIndexes.has(entityIndex)) + .map(([entityIndex, count]) => [entityIndex, count]), + damageableBrushEntityIndexes: filterIndexes(runtime.damageableBrushEntityIndexes, activeEntityIndexes), + fireballEmitterEntityIndexes: filterIndexes(runtime.fireballEmitterEntityIndexes, activeEntityIndexes), + ambientEntityIndexes: filterIndexes(runtime.ambientEntityIndexes, activeEntityIndexes), + pickupEntityIndexes: filterIndexes(runtime.pickupEntityIndexes, activeEntityIndexes), + shootableEntityIndexes: filterIndexes(runtime.shootableEntityIndexes, activeEntityIndexes), + moverEntityIndexes: filterIndexes(runtime.moverEntityIndexes, activeEntityIndexes), + moverSupportEntityIndexes: filterIndexes(runtime.moverSupportEntityIndexes, activeEntityIndexes), + }; +} + +function quakeGameLogicVariant(gameLogic, activeEntityIndexes) { + return filterEntityReferences({ + ...gameLogic, + spawnSets: Object.fromEntries( + Object.entries(gameLogic.spawnSets ?? {}) + .map(([key, indexes]) => [key, filterIndexes(indexes, activeEntityIndexes)]), + ), + entities: (gameLogic.entities ?? []).filter((entity) => activeEntityIndexes.has(entity.entityIndex)), + }, activeEntityIndexes); +} + +function quakePreparedCollisionVariant(collision, activeEntityIndexes) { + const runtime = collision.runtime + ? quakePreparedRuntimeCollisionVariant(collision.runtime, activeEntityIndexes) + : collision.runtime; + return { + ...collision, + brushModels: (collision.brushModels ?? []).filter((brushModel) => + brushModel.entityIndex === undefined || activeEntityIndexes.has(brushModel.entityIndex) + ), + ...(runtime ? { runtime } : {}), + }; +} + +function quakePreparedRuntimeCollisionVariant(runtime, activeEntityIndexes) { + const sourceBrushes = runtime.brushes ?? []; + const brushIndexMap = new Map(); + const brushes = []; + for (let index = 0; index < sourceBrushes.length; index++) { + const brush = sourceBrushes[index]; + if (brush.entityIndex !== undefined && !activeEntityIndexes.has(brush.entityIndex)) continue; + brushIndexMap.set(index, brushes.length); + brushes.push(brush); + } + return { + ...runtime, + brushes, + solidBrushIndexes: remapBrushIndexes(runtime.solidBrushIndexes, brushIndexMap), + triggerBrushIndexes: remapBrushIndexes(runtime.triggerBrushIndexes, brushIndexMap), + }; +} + +export function quakePreparedRenderBundleVariant(renderBundle, activeEntityIndexes) { + const keptLeafIndexes = []; + const leafMetadata = []; + let sourceLeafIndex = 0; + let leafCount = 0; + let atlasLeafCount = 0; + const meshHtml = (renderBundle.meshHtml ?? "").replace( + /<([bisu])\b([^>]*)><\/\1>/g, + (html, tagName) => { + const metadata = renderBundle.leafMetadata?.[sourceLeafIndex]; + const keep = !metadata || metadata.e === undefined || activeEntityIndexes.has(metadata.e); + if (keep) { + keptLeafIndexes.push(sourceLeafIndex); + leafMetadata.push(metadata); + leafCount++; + if (tagName === "s") atlasLeafCount++; + } + sourceLeafIndex++; + return keep ? html : ""; + }, + ); + return { + ...renderBundle, + meshHtml, + leafMetadata, + ...(renderBundle.debugOutlineBackgrounds + ? { debugOutlineBackgrounds: pickByIndexes(renderBundle.debugOutlineBackgrounds, keptLeafIndexes) } + : {}), + ...(renderBundle.debugTransparentOutlineBackgrounds + ? { + debugTransparentOutlineBackgrounds: pickByIndexes( + renderBundle.debugTransparentOutlineBackgrounds, + keptLeafIndexes, + ), + } + : {}), + ...(renderBundle.leafFrameStyles + ? { leafFrameStyles: pickByIndexes(renderBundle.leafFrameStyles, keptLeafIndexes) } + : {}), + ...(renderBundle.atlasResidency + ? { atlasResidency: quakeRenderBundleAtlasResidencyVariant(renderBundle.atlasResidency, keptLeafIndexes) } + : {}), + leafCount, + atlasLeafCount, + }; +} + +function quakeRenderBundleAtlasResidencyVariant(atlasResidency, keptLeafIndexes) { + return { + ...atlasResidency, + leafPageIndexes: pickByIndexes(atlasResidency.leafPageIndexes, keptLeafIndexes), + }; +} + +function filterEntityReferences(value, activeEntityIndexes, key = "") { + if (Array.isArray(value)) { + if (/EntityIndexes$/.test(key)) return filterIndexes(value, activeEntityIndexes); + return value.map((item) => filterEntityReferences(item, activeEntityIndexes)); + } + if (!value || typeof value !== "object") return value; + const out = {}; + for (const [entryKey, entryValue] of Object.entries(value)) { + if (/EntityIndex$/.test(entryKey) && typeof entryValue === "number") { + if (!activeEntityIndexes.has(entryValue)) continue; + out[entryKey] = entryValue; + continue; + } + out[entryKey] = filterEntityReferences(entryValue, activeEntityIndexes, entryKey); + } + return out; +} + +function filterIndexes(indexes = [], activeEntityIndexes) { + return indexes.filter((entityIndex) => activeEntityIndexes.has(entityIndex)); +} + +function remapBrushIndexes(indexes = [], brushIndexMap) { + return indexes + .map((index) => brushIndexMap.get(index)) + .filter((index) => Number.isInteger(index)); +} + +function pickByIndexes(items, indexes) { + return indexes.map((index) => items[index]); +} + +function quakeEntitySpawnflags(entity) { + const value = Number.parseFloat(entity?.properties?.spawnflags ?? ""); + return Number.isFinite(value) ? Math.trunc(value) : 0; +} + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} diff --git a/src/runtime/app/assetCatalogFlow.ts b/src/runtime/app/assetCatalogFlow.ts index 4d8970c..0e8f467 100644 --- a/src/runtime/app/assetCatalogFlow.ts +++ b/src/runtime/app/assetCatalogFlow.ts @@ -1,4 +1,4 @@ -import type { QuakeAssetManifest, QuakeAssetManifestMap } from "./session"; +import type { QuakeAssetManifest, QuakeAssetManifestMap, QuakeSceneMode } from "./session"; import { FALLBACK_QUAKE_ASSET_MANIFEST, quakeAssetManifestMapTitle, @@ -17,7 +17,7 @@ export interface QuakeAssetCatalogFlow { mapExists(mapName: string): boolean; mapTitle(level: QuakeAssetManifestMap): string; mountLevelSelector(renderBitmapText?: boolean): void; - sceneUrl(mapName: string): string | undefined; + sceneUrl(mapName: string, mode?: QuakeSceneMode): string | undefined; selectableLevels(): QuakeAssetManifestMap[]; setManifest(manifest: QuakeAssetManifest, options?: { renderBitmapText?: boolean }): void; startMap(): string; @@ -29,6 +29,7 @@ export function createQuakeAssetCatalogFlow( ): QuakeAssetCatalogFlow { let assetManifest = FALLBACK_QUAKE_ASSET_MANIFEST; let mapUrls = quakeAssetManifestSceneUrlMap(assetManifest); + let deathmatchMapUrls = quakeAssetManifestSceneUrlMap(assetManifest, "deathmatch"); function manifest(): QuakeAssetManifest { return assetManifest; @@ -40,6 +41,7 @@ export function createQuakeAssetCatalogFlow( ): void { assetManifest = manifest; mapUrls = quakeAssetManifestSceneUrlMap(manifest); + deathmatchMapUrls = quakeAssetManifestSceneUrlMap(manifest, "deathmatch"); mountLevelSelector(setOptions.renderBitmapText ?? false); } @@ -75,8 +77,8 @@ export function createQuakeAssetCatalogFlow( return quakeAssetManifestMapTitle(level); } - function sceneUrl(mapName: string): string | undefined { - return mapUrls.get(mapName); + function sceneUrl(mapName: string, mode: QuakeSceneMode = "singleplayer"): string | undefined { + return (mode === "deathmatch" ? deathmatchMapUrls : mapUrls).get(mapName); } return { diff --git a/src/runtime/app/damageableBrushFlow.ts b/src/runtime/app/damageableBrushFlow.ts index 6356cf9..a582086 100644 --- a/src/runtime/app/damageableBrushFlow.ts +++ b/src/runtime/app/damageableBrushFlow.ts @@ -101,6 +101,14 @@ export function createQuakeDamageableBrushFlow( scheduleDamageableBrushReset(entity); return activated; } + if (entity.classname === "func_button") { + const moverActivated = options.activateEntity(entity.index); + const targetActivated = !moverActivated && damageableBrushHasTargets(entity) + ? options.useTargets(entity) + : false; + scheduleDamageableBrushReset(entity); + return moverActivated || targetActivated; + } const activated = options.activateEntity(entity.index); scheduleDamageableBrushReset(entity); return activated; @@ -153,6 +161,10 @@ function damageableBrushMaxHealth(entity: QuakeEntity): number { return Math.max(1, Math.round(quakeEntityNumber(entity, "health", 1))); } +function damageableBrushHasTargets(entity: QuakeEntity): boolean { + return Boolean(entity.properties.target || entity.properties.killtarget); +} + function isDamageableBrushEntity(entity: QuakeEntity): boolean { if (quakeEntityNumber(entity, "health", 0) <= 0) return false; return entity.classname === "func_button" || diff --git a/src/runtime/app/gameplayInputFlow.ts b/src/runtime/app/gameplayInputFlow.ts index 7cf5e3c..e3e29c7 100644 --- a/src/runtime/app/gameplayInputFlow.ts +++ b/src/runtime/app/gameplayInputFlow.ts @@ -7,6 +7,14 @@ const QUAKE_GAMEPLAY_KEY_CODES = new Set([ "ArrowUp", "ControlLeft", "ControlRight", + "Digit1", + "Digit2", + "Digit3", + "Digit4", + "Digit5", + "Digit6", + "Digit7", + "Digit8", "KeyA", "KeyD", "KeyS", @@ -18,9 +26,20 @@ const QUAKE_GAMEPLAY_KEY_CODES = new Set([ const QUAKE_MOVE_KEY_CODES = new Set(["ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "KeyA", "KeyD", "KeyS", "KeyW"]); const QUAKE_SPEED_KEY_CODES = new Set(["ShiftLeft", "ShiftRight"]); const QUAKE_CROUCH_KEY_CODES = new Set(["ControlLeft", "ControlRight"]); +const QUAKE_WEAPON_KEY_IMPULSES = new Map([ + ["Digit1", 1], + ["Digit2", 2], + ["Digit3", 3], + ["Digit4", 4], + ["Digit5", 5], + ["Digit6", 6], + ["Digit7", 7], + ["Digit8", 8], +]); export interface QuakeGameplayInputFlowOptions { canUseGameplayInput(): boolean; + changeWeaponByImpulse(impulse: number): boolean; clearMobileLookInput(): void; clearMobileMoveInput(): void; debugFlyEnabled(): boolean; @@ -43,6 +62,7 @@ export interface QuakeGameplayInputFlow { clearParentKeyRelay(): void; handleCrouchKey(event: KeyboardEvent, pressed: boolean): boolean; handleMoveKey(event: KeyboardEvent, pressed: boolean): boolean; + handleWeaponKey(event: KeyboardEvent): boolean; isEditableTarget(target: EventTarget | null): boolean; parentKeyRelay(event: KeyboardEvent, pressed: boolean): void; shouldPreventGameplayKeyDefault(event: KeyboardEvent): boolean; @@ -121,6 +141,14 @@ export function createQuakeGameplayInputFlow( return true; } + function handleWeaponKey(event: KeyboardEvent): boolean { + if (event.repeat || options.debugFlyEnabled()) return false; + const impulse = quakeWeaponImpulseForGameplayKeyCode(event.code); + if (impulse === null) return false; + if (!options.canUseGameplayInput() || isEditableTarget(event.target)) return false; + return options.changeWeaponByImpulse(impulse); + } + return { crouchKeyCodes: QUAKE_CROUCH_KEY_CODES, moveKeyCodes: QUAKE_MOVE_KEY_CODES, @@ -130,8 +158,13 @@ export function createQuakeGameplayInputFlow( clearParentKeyRelay, handleCrouchKey, handleMoveKey, + handleWeaponKey, isEditableTarget, parentKeyRelay: parentKeyRelayEvent, shouldPreventGameplayKeyDefault, }; } + +export function quakeWeaponImpulseForGameplayKeyCode(code: string): number | null { + return QUAKE_WEAPON_KEY_IMPULSES.get(code) ?? null; +} diff --git a/src/runtime/app/input.ts b/src/runtime/app/input.ts index 199443a..0e3f1fa 100644 --- a/src/runtime/app/input.ts +++ b/src/runtime/app/input.ts @@ -18,6 +18,7 @@ export interface QuakeAppInputControllerOptions { handleDebugFlyKey(event: KeyboardEvent, pressed: boolean): boolean; handleMenuKeyDown(event: KeyboardEvent): boolean; handleMoveKey(event: KeyboardEvent, pressed: boolean): boolean; + handleWeaponKey(event: KeyboardEvent): boolean; hidePersistedLoadingConsole(): void; isEditableTarget(target: EventTarget | null): boolean; isLoading(): boolean; @@ -98,6 +99,13 @@ export function createQuakeAppInputController(options: QuakeAppInputControllerOp options.hidePersistedLoadingConsole(); return; } + if (options.handleWeaponKey(event)) { + event.preventDefault(); + event.stopPropagation(); + options.parentKeyRelay(event, true); + options.hidePersistedLoadingConsole(); + return; + } if (options.shouldPreventGameplayKeyDefault(event)) { event.preventDefault(); } diff --git a/src/runtime/app/session.ts b/src/runtime/app/session.ts index 6b19d7b..67ec06b 100644 --- a/src/runtime/app/session.ts +++ b/src/runtime/app/session.ts @@ -16,10 +16,13 @@ export interface QuakeAssetManifestMap { title?: string; pakPath?: string; sceneUrl: string; + sceneUrls?: Partial>; selectable?: boolean; modelPaths?: string[]; } +export type QuakeSceneMode = "singleplayer" | "deathmatch"; + export interface QuakeAssetManifest { version: number; assetRoot?: string; @@ -162,8 +165,18 @@ export function quakeAssetManifestMapTitle(level: QuakeAssetManifestMap): string return level.title?.trim() || level.mapName.toUpperCase(); } -export function quakeAssetManifestSceneUrlMap(manifest: QuakeAssetManifest): Map { - return new Map(manifest.maps.map((map) => [map.mapName, map.sceneUrl])); +export function quakeAssetManifestSceneUrlMap( + manifest: QuakeAssetManifest, + mode: QuakeSceneMode = "singleplayer", +): Map { + return new Map(manifest.maps.map((map) => [map.mapName, quakeAssetManifestSceneUrl(map, mode)])); +} + +export function quakeAssetManifestSceneUrl( + map: QuakeAssetManifestMap, + mode: QuakeSceneMode = "singleplayer", +): string { + return map.sceneUrls?.[mode] ?? map.sceneUrl; } export async function fetchQuakeAssetManifest(): Promise { @@ -288,6 +301,7 @@ function normalizeQuakeAssetManifestMap(value: unknown): QuakeAssetManifestMap | return { mapName, sceneUrl, + ...(isRecord(value.sceneUrls) ? { sceneUrls: normalizeQuakeAssetManifestSceneUrls(value.sceneUrls) } : {}), ...(typeof value.title === "string" ? { title: value.title } : {}), ...(typeof value.pakPath === "string" ? { pakPath: value.pakPath } : {}), ...(typeof value.selectable === "boolean" ? { selectable: value.selectable } : {}), @@ -300,6 +314,15 @@ function normalizeQuakeAssetManifestMap(value: unknown): QuakeAssetManifestMap | }; } +function normalizeQuakeAssetManifestSceneUrls(value: Record): Partial> { + const sceneUrls: Partial> = {}; + for (const mode of ["singleplayer", "deathmatch"] as const) { + const url = value[mode]; + if (typeof url === "string" && url.trim()) sceneUrls[mode] = url.trim(); + } + return sceneUrls; +} + function normalizeQuakeAssetManifestAssets(value: unknown): QuakeAssetManifest["assets"] { const fallback = FALLBACK_QUAKE_ASSET_MANIFEST.assets; if (!isRecord(value)) return fallback; diff --git a/src/runtime/movers.ts b/src/runtime/movers.ts index ce60c70..c494be0 100644 --- a/src/runtime/movers.ts +++ b/src/runtime/movers.ts @@ -375,7 +375,9 @@ export function createQuakeMoversController(options: QuakeMoversControllerOption const updateMover = (state: QuakeMoverState, now: number, dt: number): boolean => { if (state.kind === "train") return updateTrain(state, now, dt); - if (distanceSq3(state.openOffset, state.closedOffset) <= COLLISION_EPSILON) return false; + if (distanceSq3(state.openOffset, state.closedOffset) <= COLLISION_EPSILON) { + return updateZeroTravelMover(state, now); + } if (state.mode === "opening") { const next = moveOffsetToward(state.offset, state.openOffset, state.speed * dt); @@ -852,7 +854,7 @@ function createQuakeMoverState( } } - if (distanceSq3(openOffset, closedOffset) <= COLLISION_EPSILON) return null; + if (distanceSq3(openOffset, closedOffset) <= COLLISION_EPSILON && kind !== "button") return null; return { entity, model, @@ -883,6 +885,30 @@ function createQuakeMoverState( }; } +function updateZeroTravelMover(state: QuakeMoverState, now: number): boolean { + if (state.kind !== "button") return false; + if (state.mode === "opening") { + state.offset = [...state.openOffset] as Vec3; + state.mode = "open"; + state.waitUntil = state.once || state.wait < 0 || state.toggle ? Infinity : now + state.wait * 1000; + return true; + } + if (state.mode === "open") { + if (state.waitUntil !== Infinity && now >= state.waitUntil) { + state.mode = "closing"; + return true; + } + return false; + } + if (state.mode === "closing") { + state.offset = [...state.closedOffset] as Vec3; + state.mode = "closed"; + state.targetFired = false; + return true; + } + return false; +} + function quakeMoverKind(classname: string): QuakeMoverKind | null { if (classname === "func_button") return "button"; if (classname === "func_door") return "door"; diff --git a/src/runtime/multiplayer/presenceRoom.ts b/src/runtime/multiplayer/presenceRoom.ts index 831cf84..23f5eeb 100644 --- a/src/runtime/multiplayer/presenceRoom.ts +++ b/src/runtime/multiplayer/presenceRoom.ts @@ -2,10 +2,15 @@ import type * as Party from "partykit/server"; export const CSSQUAKE_PRESENCE_ROOM_ID = "global"; export const CSSQUAKE_PRESENCE_STALE_ROOM_MS = 90_000; +export const CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS = 60_000; +export const CSSQUAKE_PRESENCE_HISTORY_RETENTION_MS = 24 * 60 * 60 * 1000; const CSSQUAKE_PRESENCE_STORAGE_KEY = "cssquake-presence-rooms"; const CSSQUAKE_PRESENCE_CLEANUP_INTERVAL_MS = 30_000; const CSSQUAKE_PRESENCE_MAX_ROOMS = 2_000; const CSSQUAKE_PRESENCE_MAX_COUNT = 10_000; +const CSSQUAKE_PRESENCE_HISTORY_MAX_BUCKETS = Math.ceil( + CSSQUAKE_PRESENCE_HISTORY_RETENTION_MS / CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS, +); export interface CssQuakePresenceRoomUpdate { type: "cssquake.room-presence"; @@ -30,8 +35,25 @@ export interface CssQuakePresenceTotals { connections: number; } +export interface CssQuakePresenceHistoryBucket { + startedAt: number; + endedAt: number; + lastSeenAt: number; + samples: number; + peaks: CssQuakePresenceTotals; + latest: CssQuakePresenceTotals; +} + +export interface CssQuakePresenceHistorySnapshot { + bucketMs: number; + retentionMs: number; + peaks: CssQuakePresenceTotals; + buckets: CssQuakePresenceHistoryBucket[]; +} + interface CssQuakePresenceStorage { rooms: Record; + history: CssQuakePresenceHistoryBucket[]; } export default class CssQuakePresenceRoom implements Party.Server { @@ -54,8 +76,16 @@ export default class CssQuakePresenceRoom implements Party.Server { async onAlarm(): Promise { const storage = await this.readStorage(); - const pruned = pruneStaleRooms(storage.rooms, Date.now()); - if (pruned) await this.writeStorage(storage); + const now = Date.now(); + const pruned = pruneStaleRooms(storage.rooms, now); + let storageChanged = pruned; + if (Object.keys(storage.rooms).length > 0 || pruned) { + recordPresenceHistory(storage, now, presenceTotalsForRooms(Object.values(storage.rooms))); + storageChanged = true; + } else { + storageChanged = prunePresenceHistory(storage.history, now) || storageChanged; + } + if (storageChanged) await this.writeStorage(storage); await this.scheduleCleanup(); } @@ -70,8 +100,9 @@ export default class CssQuakePresenceRoom implements Party.Server { const update = normalizePresenceUpdate(raw); if (!update) return jsonResponse({ error: "invalid-presence-update" }, { status: 400 }); + const now = Date.now(); const storage = await this.readStorage(); - pruneStaleRooms(storage.rooms, Date.now()); + pruneStaleRooms(storage.rooms, now); if ( update.activePlayers <= 0 && update.roomPlayers <= 0 && @@ -82,55 +113,49 @@ export default class CssQuakePresenceRoom implements Party.Server { } else { storage.rooms[update.roomId] = { ...update, - lastSeenAt: Date.now(), + lastSeenAt: now, }; trimPresenceRooms(storage.rooms); } + recordPresenceHistory(storage, now, presenceTotalsForRooms(Object.values(storage.rooms))); await this.writeStorage(storage); await this.scheduleCleanup(); - return jsonResponse(await this.snapshot(storage)); + return jsonResponse(await this.snapshot(storage, now)); } - private async snapshot(storage?: CssQuakePresenceStorage): Promise<{ + private async snapshot(storage?: CssQuakePresenceStorage, snapshotAt = Date.now()): Promise<{ generatedAt: number; staleRoomMs: number; totals: CssQuakePresenceTotals; rooms: CssQuakePresenceRoomEntry[]; + history: CssQuakePresenceHistorySnapshot; }> { const activeStorage = storage ?? await this.readStorage(); - const now = Date.now(); - const pruned = pruneStaleRooms(activeStorage.rooms, now); - if (pruned) await this.writeStorage(activeStorage); + const pruned = pruneStaleRooms(activeStorage.rooms, snapshotAt); + const historyPruned = prunePresenceHistory(activeStorage.history, snapshotAt); + if (pruned || historyPruned) await this.writeStorage(activeStorage); const rooms = Object.values(activeStorage.rooms).sort((a, b) => b.lastSeenAt - a.lastSeenAt); - const totals = rooms.reduce((sum, entry) => ({ - rooms: sum.rooms + 1, - activePlayers: sum.activePlayers + entry.activePlayers, - roomPlayers: sum.roomPlayers + entry.roomPlayers, - spectators: sum.spectators + entry.spectators, - connections: sum.connections + entry.connections, - }), { - rooms: 0, - activePlayers: 0, - roomPlayers: 0, - spectators: 0, - connections: 0, - }); + const totals = presenceTotalsForRooms(rooms); return { - generatedAt: now, + generatedAt: snapshotAt, staleRoomMs: CSSQUAKE_PRESENCE_STALE_ROOM_MS, totals, rooms, + history: presenceHistorySnapshot(activeStorage.history), }; } private async readStorage(): Promise { const stored = await this.room.storage.get(CSSQUAKE_PRESENCE_STORAGE_KEY); - if (!stored || !isRecord(stored.rooms)) return { rooms: {} }; + if (!isRecord(stored) || !isRecord(stored.rooms)) return { rooms: {}, history: [] }; return { rooms: Object.fromEntries( Object.entries(stored.rooms).filter(([, entry]) => isPresenceRoomEntry(entry)), ), + history: Array.isArray(stored.history) + ? stored.history.filter((bucket) => isPresenceHistoryBucket(bucket)).sort((a, b) => a.startedAt - b.startedAt) + : [], }; } @@ -185,6 +210,25 @@ function isPresenceRoomEntry(value: unknown): value is CssQuakePresenceRoomEntry Number.isFinite(value.lastSeenAt); } +function isPresenceHistoryBucket(value: unknown): value is CssQuakePresenceHistoryBucket { + return isRecord(value) && + Number.isFinite(value.startedAt) && + Number.isFinite(value.endedAt) && + Number.isFinite(value.lastSeenAt) && + Number.isFinite(value.samples) && + isPresenceTotals(value.peaks) && + isPresenceTotals(value.latest); +} + +function isPresenceTotals(value: unknown): value is CssQuakePresenceTotals { + return isRecord(value) && + Number.isFinite(value.rooms) && + Number.isFinite(value.activePlayers) && + Number.isFinite(value.roomPlayers) && + Number.isFinite(value.spectators) && + Number.isFinite(value.connections); +} + function pruneStaleRooms(rooms: Record, now: number): boolean { let pruned = false; for (const [roomId, entry] of Object.entries(rooms)) { @@ -203,7 +247,116 @@ function trimPresenceRooms(rooms: Record): vo .slice(CSSQUAKE_PRESENCE_MAX_ROOMS) .forEach(([roomId]) => { delete rooms[roomId]; + }); +} + +function recordPresenceHistory(storage: CssQuakePresenceStorage, now: number, totals: CssQuakePresenceTotals): void { + const startedAt = presenceHistoryBucketStart(now); + const latest = clonePresenceTotals(totals); + const existing = storage.history.find((bucket) => bucket.startedAt === startedAt); + if (existing) { + existing.lastSeenAt = now; + existing.samples += 1; + existing.latest = latest; + existing.peaks = maxPresenceTotals(existing.peaks, totals); + } else { + storage.history.push({ + startedAt, + endedAt: startedAt + CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS, + lastSeenAt: now, + samples: 1, + peaks: clonePresenceTotals(totals), + latest, }); + } + prunePresenceHistory(storage.history, now); +} + +function presenceHistorySnapshot(history: CssQuakePresenceHistoryBucket[]): CssQuakePresenceHistorySnapshot { + const buckets = history + .slice() + .sort((a, b) => a.startedAt - b.startedAt) + .map(clonePresenceHistoryBucket); + return { + bucketMs: CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS, + retentionMs: CSSQUAKE_PRESENCE_HISTORY_RETENTION_MS, + peaks: buckets.reduce( + (peaks, bucket) => maxPresenceTotals(peaks, bucket.peaks), + emptyPresenceTotals(), + ), + buckets, + }; +} + +function prunePresenceHistory(history: CssQuakePresenceHistoryBucket[], now: number): boolean { + let pruned = false; + const oldestStartedAt = presenceHistoryBucketStart(now - CSSQUAKE_PRESENCE_HISTORY_RETENTION_MS); + for (let index = history.length - 1; index >= 0; index -= 1) { + if (history[index].startedAt >= oldestStartedAt) continue; + history.splice(index, 1); + pruned = true; + } + history.sort((a, b) => a.startedAt - b.startedAt); + if (history.length > CSSQUAKE_PRESENCE_HISTORY_MAX_BUCKETS) { + history.splice(0, history.length - CSSQUAKE_PRESENCE_HISTORY_MAX_BUCKETS); + pruned = true; + } + return pruned; +} + +function presenceHistoryBucketStart(now: number): number { + return Math.floor(now / CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS) * CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS; +} + +function presenceTotalsForRooms(rooms: CssQuakePresenceRoomEntry[]): CssQuakePresenceTotals { + return rooms.reduce((sum, entry) => ({ + rooms: sum.rooms + 1, + activePlayers: sum.activePlayers + entry.activePlayers, + roomPlayers: sum.roomPlayers + entry.roomPlayers, + spectators: sum.spectators + entry.spectators, + connections: sum.connections + entry.connections, + }), emptyPresenceTotals()); +} + +function emptyPresenceTotals(): CssQuakePresenceTotals { + return { + rooms: 0, + activePlayers: 0, + roomPlayers: 0, + spectators: 0, + connections: 0, + }; +} + +function maxPresenceTotals(left: CssQuakePresenceTotals, right: CssQuakePresenceTotals): CssQuakePresenceTotals { + return { + rooms: Math.max(left.rooms, right.rooms), + activePlayers: Math.max(left.activePlayers, right.activePlayers), + roomPlayers: Math.max(left.roomPlayers, right.roomPlayers), + spectators: Math.max(left.spectators, right.spectators), + connections: Math.max(left.connections, right.connections), + }; +} + +function clonePresenceHistoryBucket(bucket: CssQuakePresenceHistoryBucket): CssQuakePresenceHistoryBucket { + return { + startedAt: bucket.startedAt, + endedAt: bucket.endedAt, + lastSeenAt: bucket.lastSeenAt, + samples: bucket.samples, + peaks: clonePresenceTotals(bucket.peaks), + latest: clonePresenceTotals(bucket.latest), + }; +} + +function clonePresenceTotals(totals: CssQuakePresenceTotals): CssQuakePresenceTotals { + return { + rooms: totals.rooms, + activePlayers: totals.activePlayers, + roomPlayers: totals.roomPlayers, + spectators: totals.spectators, + connections: totals.connections, + }; } function sanitizePresenceText(value: unknown, maxLength: number): string | null { diff --git a/src/runtime/player.ts b/src/runtime/player.ts index df899cc..8abec85 100644 --- a/src/runtime/player.ts +++ b/src/runtime/player.ts @@ -10,7 +10,11 @@ import { QUAKE_PLAYER_MINS_Z, STEP_HEIGHT, } from "./constants"; -import { QUAKE_CONTENTS_WATER, type QuakeHazardDamage } from "./hazards"; +import { + QUAKE_CONTENTS_WATER, + quakePlayerWaterLevel, + type QuakeHazardDamage, +} from "./hazards"; import { markQuakeTrace } from "./debug/traceMarks"; import { applyQuakeDamageToInventory, @@ -33,6 +37,7 @@ import { quakePlayerFallDamageFromVelocityZ, updateQuakePlayerPhysics, type QuakePlayerMoveCommand, + type QuakePlayerWaterMoveState, } from "./playerPhysics"; const FALL_DT_CLAMP = 0.05; @@ -324,6 +329,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption let jumpQueued = false; let jumpReleased = true; let currentGrounded = true; + let lastMoveWaterLevel = 0; let lastMoveStepDebug: Record | null = null; const moveCommand: QuakePlayerMoveCommand = { forwardMove: 0, @@ -441,6 +447,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption stopMoveFrame(); moveVelocity = [0, 0, 0]; fallDamageVelocityZ = 0; + lastMoveWaterLevel = 0; stopFalling(); stopPush(); stopDeathToss(); @@ -516,6 +523,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption currentEyeHeight = standingEyeHeight; currentCrouching = false; currentGrounded = true; + lastMoveWaterLevel = 0; moveVelocity = [0, 0, 0]; nextDamageAt = 0; lastGroundEntityIndex = null; @@ -535,6 +543,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption standingEyeHeight = spawn.eyeHeight; currentEyeHeight = standingEyeHeight; currentCrouching = false; + lastMoveWaterLevel = 0; currentGroundZ = collisionWorld?.floorAt( spawn.origin[0], spawn.origin[1], @@ -872,7 +881,8 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption return hasDirectionalMoveInput() || Math.abs(moveAnalogX) > PLAYER_MOVE_ANALOG_DEADZONE || Math.abs(moveAnalogY) > PLAYER_MOVE_ANALOG_DEADZONE || - jumpQueued; + jumpQueued || + (lastMoveWaterLevel >= 2 && Boolean(moveKeyBits & QUAKE_MOVE_JUMP_BIT)); } function hasDirectionalMoveInput(): boolean { @@ -880,13 +890,13 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption } function hasMoveMotion(): boolean { - return !currentGrounded || + return (!currentGrounded && lastMoveWaterLevel < 2) || moveVelocity[0] * moveVelocity[0] + moveVelocity[1] * moveVelocity[1] > PLAYER_MOVE_STOP_SPEED_SQ || Math.abs(moveVelocity[2]) > PLAYER_MOVE_STOP_SPEED || hasMoveInput(); } - function updateCurrentMoveCommand(): QuakePlayerMoveCommand { + function updateCurrentMoveCommand(waterLevel = lastMoveWaterLevel): QuakePlayerMoveCommand { let forwardMove = 0; let sideMove = 0; if (moveKeyBits & QUAKE_MOVE_FORWARD_BIT) forwardMove += QUAKE_PMOVE_FORWARD_SPEED; @@ -902,7 +912,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption sideMove *= QUAKE_PMOVE_SPEED_KEY_MULTIPLIER; } moveCommand.forwardMove = forwardMove; - moveCommand.jump = jumpQueued; + moveCommand.jump = jumpQueued || (waterLevel >= 2 && Boolean(moveKeyBits & QUAKE_MOVE_JUMP_BIT)); moveCommand.sideMove = sideMove; moveCommand.yawDegrees = options.getYaw(); return moveCommand; @@ -928,13 +938,15 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption const wasGroundedAtTickStart = currentGrounded; const origin = options.controls.getOrigin(); const footZ = origin[2] - currentEyeHeight; + const waterMove = quakePlayerCurrentWaterMove(collisionWorld, origin); + lastMoveWaterLevel = waterMove.waterLevel ?? 0; const snapGroundZ = !currentGrounded && moveVelocity[2] <= 0 ? collisionWorld.floorAt(origin[0], origin[1], footZ + GROUND_SNAP, footZ - GROUND_SNAP) : null; const groundedForPhysics = currentGrounded || snapGroundZ !== null; if (snapGroundZ !== null) currentGroundZ = snapGroundZ; currentGrounded = groundedForPhysics; - const command = updateCurrentMoveCommand(); + const command = updateCurrentMoveCommand(waterMove.waterLevel ?? 0); jumpQueued = false; const frictionScale = groundedForPhysics ? quakePlayerEdgeFriction(collisionWorld, origin, currentEyeHeight, moveVelocity) @@ -947,6 +959,7 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption options.gravity, options.jumpVelocity, frictionScale, + waterMove, ); currentGrounded = physicsGrounded; @@ -986,6 +999,8 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption moveStepDebug.resolvedZ = resolved.origin[2]; moveStepDebug.targetZ = target[2]; moveStepDebug.upwardGroundSnapIgnored = upwardGroundSnapIgnored; + moveStepDebug.waterContents = waterMove.contents ?? null; + moveStepDebug.waterLevel = waterMove.waterLevel ?? 0; lastMoveStepDebug = moveStepDebug; const intendedDeltaZ = target[2] - origin[2]; const actualDeltaX = resolved.origin[0] - origin[0]; @@ -1009,6 +1024,19 @@ export function createQuakePlayerController(options: QuakePlayerControllerOption if (moveFrame === null && hasMoveMotion()) scheduleMoveFrame(); }; + function quakePlayerCurrentWaterMove( + collisionWorld: QuakeCollisionWorld, + origin: [number, number, number], + ): QuakePlayerWaterMoveState { + const contentsAt = collisionWorld.contentsAt; + if (!contentsAt) return { contents: null, waterLevel: 0 }; + const waterLevel = quakePlayerWaterLevel(contentsAt, origin, currentEyeHeight); + if (waterLevel <= 0) return { contents: null, waterLevel: 0 }; + const footZ = origin[2] - currentEyeHeight; + const contents = contentsAt([origin[0], origin[1], footZ + QUAKE_COLLISION_UNIT_SCALE]) ?? null; + return { contents, waterLevel }; + } + function quakePlayerEdgeFriction( collisionWorld: QuakeCollisionWorld, origin: [number, number, number], diff --git a/src/runtime/playerPhysics.ts b/src/runtime/playerPhysics.ts index b2ab508..58ad756 100644 --- a/src/runtime/playerPhysics.ts +++ b/src/runtime/playerPhysics.ts @@ -1,6 +1,7 @@ import type { Vec3 } from "@layoutit/polycss"; import { COLLISION_EPSILON, QUAKE_COLLISION_UNIT_SCALE } from "./constants"; +import { QUAKE_CONTENTS_SLIME, QUAKE_CONTENTS_WATER } from "./hazards"; export const QUAKE_PMOVE_DT_CLAMP = 0.05; export const QUAKE_PMOVE_MAX_SPEED = 320 * QUAKE_COLLISION_UNIT_SCALE; @@ -13,6 +14,10 @@ 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; +export const QUAKE_PMOVE_WATER_DAMPING = 0.8; +export const QUAKE_PMOVE_WATER_SWIM_VELOCITY = 100 * QUAKE_COLLISION_UNIT_SCALE; +export const QUAKE_PMOVE_SLIME_SWIM_VELOCITY = 80 * QUAKE_COLLISION_UNIT_SCALE; +export const QUAKE_PMOVE_OTHER_LIQUID_SWIM_VELOCITY = 50 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_PMOVE_ACCELERATE = 10; const QUAKE_PMOVE_AIR_ACCELERATE = 10; @@ -27,6 +32,11 @@ export interface QuakePlayerMoveCommand { yawDegrees: number; } +export interface QuakePlayerWaterMoveState { + contents?: number | null; + waterLevel?: number; +} + export function updateQuakePlayerPhysics( velocity: Vec3, command: QuakePlayerMoveCommand, @@ -35,7 +45,10 @@ export function updateQuakePlayerPhysics( gravity: number, jumpVelocity: number, frictionScale = 1, + waterMove?: QuakePlayerWaterMoveState, ): boolean { + const waterLevel = Math.max(0, Math.floor(waterMove?.waterLevel ?? 0)); + const swimming = waterLevel >= 2; const yaw = (command.yawDegrees * Math.PI) / 180; const forwardX = -Math.cos(yaw); const forwardY = -Math.sin(yaw); @@ -58,14 +71,20 @@ export function updateQuakePlayerPhysics( applyQuakeFriction(velocity, dt, frictionScale); applyQuakeAccelerate(velocity, wishDirectionX, wishDirectionY, wishSpeed, QUAKE_PMOVE_ACCELERATE, dt); if (command.jump) { - velocity[2] += jumpVelocity; + velocity[2] = swimming + ? quakePlayerSwimVelocityForContents(waterMove?.contents) + : velocity[2] + jumpVelocity; grounded = false; } } else { applyQuakeAirAccelerate(velocity, wishVelocityX, wishVelocityY, wishSpeed, dt); + if (command.jump && swimming) { + velocity[2] = quakePlayerSwimVelocityForContents(waterMove?.contents); + } } - if (!grounded) velocity[2] -= gravity * dt; + if (!grounded && !swimming) velocity[2] -= gravity * dt; + if (waterLevel > 0) applyQuakeWaterDamping(velocity, waterLevel, dt); return grounded; } @@ -74,6 +93,12 @@ export function quakePlayerFallDamageFromVelocityZ(velocityZ: number): number { return Number.isFinite(velocityZ) && velocityZ < -QUAKE_PLAYER_FALL_DAMAGE_VELOCITY ? 5 : 0; } +export function quakePlayerSwimVelocityForContents(contents: number | null | undefined): number { + if (contents === QUAKE_CONTENTS_WATER) return QUAKE_PMOVE_WATER_SWIM_VELOCITY; + if (contents === QUAKE_CONTENTS_SLIME) return QUAKE_PMOVE_SLIME_SWIM_VELOCITY; + return QUAKE_PMOVE_OTHER_LIQUID_SWIM_VELOCITY; +} + function applyQuakeFriction(velocity: Vec3, dt: number, frictionScale: number): void { const speed = Math.hypot(velocity[0], velocity[1]); if (speed <= COLLISION_EPSILON) return; @@ -102,6 +127,13 @@ function applyQuakeAccelerate( velocity[1] += accelspeed * wishdirY; } +function applyQuakeWaterDamping(velocity: Vec3, waterLevel: number, dt: number): void { + const damping = Math.max(0, 1 - QUAKE_PMOVE_WATER_DAMPING * waterLevel * dt); + velocity[0] *= damping; + velocity[1] *= damping; + velocity[2] *= damping; +} + function applyQuakeAirAccelerate( velocity: Vec3, wishVelocityX: number, diff --git a/src/runtime/weapons.ts b/src/runtime/weapons.ts index 4c39a7f..3ec2d99 100644 --- a/src/runtime/weapons.ts +++ b/src/runtime/weapons.ts @@ -105,6 +105,7 @@ export interface QuakeWeaponsControllerOptions { hasViewmodel(): boolean; getCollisionWorld(): QuakeCollisionWorld | null; getEntities(): ReadonlyMap; + getDamageableBrushTargets?(): Iterable; getShootables(): Iterable; getPlayerEyeHeight(): number; getPlayerWaterLevel(): number; @@ -718,6 +719,7 @@ export function createQuakeWeaponsController({ hasViewmodel, getCollisionWorld, getEntities, + getDamageableBrushTargets, getShootables, getPlayerEyeHeight, getPlayerWaterLevel, @@ -998,7 +1000,11 @@ export function createQuakeWeaponsController({ function traceWeaponRay(ray: QuakeViewRay): QuakeUseTrace | null { const worldTrace = getCollisionWorld()?.traceUse?.(ray.origin, ray.end) ?? null; - return traceShootables(ray, worldTrace?.fraction ?? 1, 0) ?? worldTrace; + return ( + traceShootables(ray, worldTrace?.fraction ?? 1, 0) ?? + traceDamageableBrushTargets(ray) ?? + worldTrace + ); } function traceShootables(ray: QuakeViewRay, maxFraction: number, monsterTouchHullExpansion: number): QuakeUseTrace | null { @@ -1018,6 +1024,17 @@ export function createQuakeWeaponsController({ return best; } + function traceDamageableBrushTargets(ray: QuakeViewRay): QuakeUseTrace | null { + let best: QuakeUseTrace | null = null; + for (const target of getDamageableBrushTargets?.() ?? []) { + if (target.dead || !isShootableFuncButtonEntity(target.entity)) continue; + const trace = rayTraceAabb(ray, target.bounds.min, target.bounds.max, 1, target.entity); + if (!trace) continue; + if (!best || trace.fraction < best.fraction) best = trace; + } + return best; + } + function quakeAimTrace(ray: QuakeViewRay): QuakeUseTrace | null { const collisionWorld = getCollisionWorld(); let best: { score: number; trace: QuakeUseTrace } | null = null; @@ -1997,6 +2014,10 @@ function isShootableBrushEntity(entity: QuakeEntity): boolean { entity.classname === "trigger_secret"; } +function isShootableFuncButtonEntity(entity: QuakeEntity): boolean { + return entity.classname === "func_button" && quakeEntityNumber(entity, "health", 0) > 0; +} + function forwardDirection(rotX: number, rotY: number): Vec3 { const rx = (rotX * Math.PI) / 180; const ry = (rotY * Math.PI) / 180; diff --git a/test/gameplay/playerWaterMove.test.mjs b/test/gameplay/playerWaterMove.test.mjs new file mode 100644 index 0000000..745cdd3 --- /dev/null +++ b/test/gameplay/playerWaterMove.test.mjs @@ -0,0 +1,105 @@ +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 SCALE = constants.QUAKE_COLLISION_UNIT_SCALE; +const MOVE_COMMAND_IDLE = { + forwardMove: 0, + jump: false, + sideMove: 0, + yawDegrees: 270, +}; + +test("waterlevel two jump uses Quake swim-up velocity instead of normal jump velocity", () => { + const velocity = [0, 0, -40 * SCALE]; + const grounded = physics.updateQuakePlayerPhysics( + velocity, + { ...MOVE_COMMAND_IDLE, jump: true }, + false, + 0, + 800 * SCALE, + 270 * SCALE, + 1, + { contents: hazards.QUAKE_CONTENTS_WATER, waterLevel: 2 }, + ); + + assert.equal(grounded, false); + assertApproxEqual(velocity[2], 100 * SCALE); +}); + +test("swim-up velocity follows liquid contents", () => { + assert.equal( + physics.quakePlayerSwimVelocityForContents(hazards.QUAKE_CONTENTS_WATER), + 100 * SCALE, + ); + assert.equal( + physics.quakePlayerSwimVelocityForContents(hazards.QUAKE_CONTENTS_SLIME), + 80 * SCALE, + ); + assert.equal( + physics.quakePlayerSwimVelocityForContents(hazards.QUAKE_CONTENTS_LAVA), + 50 * SCALE, + ); +}); + +test("swimming skips normal falling gravity while submerged", () => { + const velocity = [0, 0, 0]; + physics.updateQuakePlayerPhysics( + velocity, + MOVE_COMMAND_IDLE, + false, + 0.1, + 800 * SCALE, + 270 * SCALE, + 1, + { contents: hazards.QUAKE_CONTENTS_WATER, waterLevel: 3 }, + ); + + assert.equal(velocity[2], 0); +}); + +test("liquid movement damping scales all velocity axes by waterlevel", () => { + const velocity = [10 * SCALE, -20 * SCALE, 30 * SCALE]; + physics.updateQuakePlayerPhysics( + velocity, + MOVE_COMMAND_IDLE, + false, + 0.25, + 800 * SCALE, + 270 * SCALE, + 1, + { contents: hazards.QUAKE_CONTENTS_WATER, waterLevel: 2 }, + ); + + assertApproxEqual(velocity[0], 6 * SCALE); + assertApproxEqual(velocity[1], -12 * SCALE); + assertApproxEqual(velocity[2], 18 * SCALE); +}); + +test("feet-only water still applies normal gravity before liquid damping", () => { + const velocity = [0, 0, 0]; + physics.updateQuakePlayerPhysics( + velocity, + MOVE_COMMAND_IDLE, + false, + 0.25, + 800 * SCALE, + 270 * SCALE, + 1, + { contents: hazards.QUAKE_CONTENTS_WATER, waterLevel: 1 }, + ); + + assertApproxEqual(velocity[2], -160 * SCALE); +}); + +function assertApproxEqual(actual, expected) { + assert.ok( + Math.abs(actual - expected) < 1e-9, + `expected ${actual} to approximately equal ${expected}`, + ); +} diff --git a/test/gameplay/weaponDamageableBrushTargets.test.mjs b/test/gameplay/weaponDamageableBrushTargets.test.mjs new file mode 100644 index 0000000..198033d --- /dev/null +++ b/test/gameplay/weaponDamageableBrushTargets.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const weapons = await importTsModule("src/runtime/weapons.ts"); + +test("health func_button brush targets can win over earlier world trace", () => { + const controller = createWeaponsController({ + damageableBrushTargets: [ + damageableBrushTarget({ + classname: "func_button", + health: 1, + index: 42, + }), + ], + entities: [ + quakeEntity({ + classname: "func_button", + health: 1, + index: 42, + }), + ], + }); + + const trace = controller.weaponTraceAtCrosshair(); + + assert.equal(trace?.classname, "func_button"); + assert.equal(trace?.entityIndex, 42); +}); + +test("non-button damageable brush targets do not bypass world trace", () => { + const controller = createWeaponsController({ + damageableBrushTargets: [ + damageableBrushTarget({ + classname: "func_door", + health: 1, + index: 43, + }), + ], + entities: [ + quakeEntity({ + classname: "func_door", + health: 1, + index: 43, + }), + ], + }); + + const trace = controller.weaponTraceAtCrosshair(); + + assert.equal(trace?.classname, "worldspawn"); + assert.equal(trace?.entityIndex, undefined); +}); + +function createWeaponsController({ damageableBrushTargets, entities }) { + const entityByIndex = new Map(entities.map((entity) => [entity.index, entity])); + return weapons.createQuakeWeaponsController({ + scene: { camera: { state: { rotX: 90, rotY: 180 } } }, + controls: { getOrigin: () => [0, 0, 0] }, + canUseGameplayInput: () => true, + consumeAmmo: () => undefined, + damageBrushEntity: () => false, + damagePlayer: () => false, + damageShootable: () => false, + getActiveWeapon: () => "nailgun", + getAmmo: () => 100, + getCollisionWorld: () => ({ + traceUse: () => ({ + classname: "worldspawn", + end: [0.1, 0, 0], + fraction: 0.01, + planeNormal: [-1, 0, 0], + }), + }), + getDamageableBrushTargets: () => damageableBrushTargets, + getEntities: () => entityByIndex, + getPlayerEyeHeight: () => 0.92, + getPlayerWaterLevel: () => 0, + getShootables: () => [], + hasViewmodel: () => true, + onHit: () => undefined, + playFireAnimation: () => undefined, + playFireSound: () => undefined, + selectBestWeapon: () => "nailgun", + syncCrosshairTarget: () => undefined, + syncHud: () => undefined, + }); +} + +function damageableBrushTarget({ classname, health, index }) { + return { + dead: false, + entity: quakeEntity({ classname, health, index }), + origin: [0, 0, 0], + bounds: { + min: [-100, -100, -100], + max: [100, 100, 100], + }, + }; +} + +function quakeEntity({ classname, health, index }) { + return { + classname, + index, + properties: { + classname, + health, + }, + }; +} diff --git a/test/multiplayer/presence.test.mjs b/test/multiplayer/presence.test.mjs index f07d19b..99b3930 100644 --- a/test/multiplayer/presence.test.mjs +++ b/test/multiplayer/presence.test.mjs @@ -151,6 +151,12 @@ test("presence room aggregates active room counts and removes empty rooms", asyn assert.equal(snapshot.totals.connections, 3); assert.equal(snapshot.totals.rooms, 1); assert.equal(snapshot.rooms[0].roomId, "cssquake-auto-e1m1-abc123"); + assert.equal(snapshot.history.bucketMs, presenceRoomModule.CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS); + assert.equal(snapshot.history.retentionMs, presenceRoomModule.CSSQUAKE_PRESENCE_HISTORY_RETENTION_MS); + assert.equal(snapshot.history.buckets.length, 1); + assert.equal(snapshot.history.peaks.activePlayers, 2); + assert.equal(snapshot.history.buckets[0].peaks.activePlayers, 2); + assert.equal(snapshot.history.buckets[0].latest.activePlayers, 2); assert.ok(storage.alarmAt > Date.now()); response = await presenceRoom.onRequest(request("POST", { @@ -164,6 +170,71 @@ test("presence room aggregates active room counts and removes empty rooms", asyn assert.equal(snapshot.totals.activePlayers, 0); assert.equal(snapshot.totals.rooms, 0); assert.deepEqual(snapshot.rooms, []); + assert.equal(snapshot.history.buckets.length, 1); + assert.equal(snapshot.history.buckets[0].samples, 2); + assert.equal(snapshot.history.buckets[0].peaks.activePlayers, 2); + assert.equal(snapshot.history.buckets[0].latest.activePlayers, 0); + }); +}); + +test("presence room records minute peak history and prunes old buckets", async () => { + await withFakeNow(10_000, async (clock) => { + const { room } = createFakePresenceRoom(); + const PresenceRoom = presenceRoomModule.default; + const presenceRoom = new PresenceRoom(room); + const update = presenceRoomModule.createCssQuakePresenceUpdatePayload({ + roomId: "cssquake-auto-e1m1-history", + mapName: "e1m1", + gameplayFactsHash: "facts-a", + activePlayers: 1, + roomPlayers: 1, + spectators: 0, + connections: 1, + }); + + let snapshot = await (await presenceRoom.onRequest(request("POST", update))).json(); + assert.equal(snapshot.history.buckets.length, 1); + assert.equal(snapshot.history.buckets[0].startedAt, 0); + assert.equal(snapshot.history.buckets[0].peaks.activePlayers, 1); + assert.equal(snapshot.history.buckets[0].latest.activePlayers, 1); + + clock.advance(10_000); + snapshot = await (await presenceRoom.onRequest(request("POST", { + ...update, + activePlayers: 0, + roomPlayers: 0, + connections: 0, + }))).json(); + assert.equal(snapshot.history.buckets.length, 1); + assert.equal(snapshot.history.buckets[0].samples, 2); + assert.equal(snapshot.history.buckets[0].peaks.activePlayers, 1); + assert.equal(snapshot.history.buckets[0].latest.activePlayers, 0); + + clock.advance(presenceRoomModule.CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS); + snapshot = await (await presenceRoom.onRequest(request("POST", { + ...update, + activePlayers: 3, + roomPlayers: 3, + connections: 3, + }))).json(); + assert.equal(snapshot.history.buckets.length, 2); + assert.equal(snapshot.history.peaks.activePlayers, 3); + assert.equal(snapshot.history.buckets.at(-1).peaks.connections, 3); + + clock.advance( + presenceRoomModule.CSSQUAKE_PRESENCE_HISTORY_RETENTION_MS + + presenceRoomModule.CSSQUAKE_PRESENCE_HISTORY_BUCKET_MS, + ); + snapshot = await (await presenceRoom.onRequest(request("POST", { + ...update, + roomId: "cssquake-auto-e1m1-fresh-history", + activePlayers: 1, + roomPlayers: 1, + connections: 1, + }))).json(); + assert.equal(snapshot.history.buckets.length, 1); + assert.equal(snapshot.history.buckets[0].peaks.activePlayers, 1); + assert.equal(snapshot.history.peaks.activePlayers, 1); }); }); diff --git a/test/prepare/sceneVariants.test.mjs b/test/prepare/sceneVariants.test.mjs new file mode 100644 index 0000000..740de5e --- /dev/null +++ b/test/prepare/sceneVariants.test.mjs @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { quakePreparedSceneVariant } from "../../src/prepare/sceneVariants.mjs"; + +function preparedFixture() { + const entities = [ + entity(0, "worldspawn", 0), + entity(1, "func_wall", 0), + entity(2, "func_wall", 256 | 512 | 1024), + entity(3, "item_shells", 2048), + entity(4, "info_player_deathmatch", 0), + ]; + return { + version: 2, + textureCount: 1, + faceCount: 4, + sourceFaceCount: 4, + label: "maps/e1m1.bsp", + warnings: [], + entities, + entityManifest: { + totals: { entities: 5, active: 5, metadataOnly: 0, ignored: 0, byClassname: {}, byCategory: {} }, + entries: entities.map((item) => ({ + entityIndex: item.index, + classname: item.classname, + category: item.classname === "worldspawn" ? "worldspawn" : "brush", + runtimeStatus: "active", + spawnflags: Number(item.properties.spawnflags ?? 0), + })), + starts: [], + pickups: [{ entityIndex: 3, classname: "item_shells", origin: { x: 0, y: 0, z: 0 }, spawnflags: 2048 }], + monsters: [], + triggers: [], + movers: [], + teleporters: [], + exits: [], + lights: [], + counters: [], + secrets: [], + inert: [], + runtime: { + targetEntities: { t1: [1, 2, 3, 4] }, + triggerCounterCounts: [[2, 1], [3, 1]], + damageableBrushEntityIndexes: [1, 2], + fireballEmitterEntityIndexes: [], + ambientEntityIndexes: [], + pickupEntityIndexes: [3], + shootableEntityIndexes: [], + moverEntityIndexes: [1, 2], + moverSupportEntityIndexes: [3], + }, + }, + gameLogic: { + spawnSets: { + singleplayerEasy: [0, 1, 3], + singleplayerNormal: [0, 1, 3], + singleplayerHard: [0, 1, 3], + }, + entities: [ + { entityIndex: 1, modeMask: ["singleplayer:easy"], resolvedTrigger: { targetUse: { targetEntityIndexes: [1, 2, 3] } } }, + { entityIndex: 2, modeMask: [] }, + { entityIndex: 3, modeMask: ["singleplayer:easy"] }, + ], + brushModels: {}, + }, + models: [], + spawn: { origin: [0, 0, 0], groundZ: 0, eyeHeight: 1, rotX: 0, rotY: 0 }, + visibility: { + brushModels: [ + { entityIndex: 1, modelIndex: 1 }, + { entityIndex: 2, modelIndex: 2 }, + { entityIndex: 3, modelIndex: 3 }, + ], + }, + collision: { + planes: [], + clipNodes: [], + headNodes: [0, 0, 0, 0], + hulls: [], + models: [], + brushModels: [ + { entityIndex: 1, modelIndex: 1 }, + { entityIndex: 2, modelIndex: 2 }, + { entityIndex: 3, modelIndex: 3 }, + ], + pivot: { x: 0, y: 0, z: 0 }, + runtime: { + groundGrid: { cellSize: 1, height: 1, nullSample: -32768, origin: [0, 0], samples: "", width: 1, zScale: 1 }, + hullMinsZ: 0, + planes: [], + brushes: [ + { headNode: 0, kind: "solid", baseOffset: [0, 0, 0], modelIndex: 0, classname: "worldspawn" }, + { headNode: 0, kind: "solid", baseOffset: [0, 0, 0], entityIndex: 1, modelIndex: 1, classname: "func_wall" }, + { headNode: 0, kind: "solid", baseOffset: [0, 0, 0], entityIndex: 2, modelIndex: 2, classname: "func_wall" }, + { headNode: 0, kind: "solid", baseOffset: [0, 0, 0], entityIndex: 3, modelIndex: 3, classname: "item_shells" }, + ], + solidBrushIndexes: [0, 1, 2, 3], + triggerBrushIndexes: [], + }, + }, + renderBundle: renderBundleFixture(), + }; +} + +function entity(index, classname, spawnflags) { + return { + index, + classname, + properties: { classname, spawnflags: String(spawnflags) }, + }; +} + +function renderBundleFixture() { + return { + version: 1, + kind: "polycss-mesh", + polycssVersion: "test", + textureLighting: "baked", + textureQuality: 1, + meshHtml: '
', + assetUrls: [], + assetUrlsComplete: true, + leafMetadata: [{ f: 0 }, { f: 1, e: 1 }, { f: 2, e: 2 }, { f: 3, e: 3 }, { f: 4, e: 4 }], + leafFrameStyles: [["w"], ["a"], ["dm"], ["sp"], ["spawn"]], + polygonCount: 5, + leafCount: 5, + atlasLeafCount: 5, + atlasResidency: { + version: 1, + mode: "pvs-pages", + pageSize: 4096, + pages: [], + leafPageIndexes: [0, 1, 2, 3, 4], + visibilityLeafPages: [], + visibilityLeafPrewarmPages: [], + }, + }; +} + +test("singleplayer prepared scene variant removes deathmatch-only brush surfaces and collision", () => { + const variant = quakePreparedSceneVariant(preparedFixture(), "singleplayer"); + + assert.deepEqual(variant.entities.map((item) => item.index), [0, 1, 3]); + assert.deepEqual(variant.renderBundle.leafMetadata.map((item) => item?.e ?? 0), [0, 1, 3]); + assert.equal(variant.renderBundle.leafCount, 3); + assert.equal(variant.collision.runtime.brushes.some((brush) => brush.entityIndex === 2), false); + assert.deepEqual(variant.collision.runtime.solidBrushIndexes, [0, 1, 2]); + assert.deepEqual(variant.entityManifest.runtime.targetEntities, { t1: [1, 3] }); + assert.deepEqual( + variant.gameLogic.entities[0].resolvedTrigger.targetUse.targetEntityIndexes, + [1, 3], + ); +}); + +test("deathmatch prepared scene variant keeps deathmatch-only brush and removes NOT_DEATHMATCH entities", () => { + const variant = quakePreparedSceneVariant(preparedFixture(), "deathmatch"); + + assert.deepEqual(variant.entities.map((item) => item.index), [0, 1, 2, 4]); + assert.deepEqual(variant.renderBundle.leafMetadata.map((item) => item?.e ?? 0), [0, 1, 2, 4]); + assert.equal(variant.collision.runtime.brushes.some((brush) => brush.entityIndex === 2), true); + assert.equal(variant.collision.runtime.brushes.some((brush) => brush.entityIndex === 3), false); + assert.deepEqual(variant.entityManifest.runtime.targetEntities, { t1: [1, 2, 4] }); +}); diff --git a/test/runtime/damageableBrushFlow.test.mjs b/test/runtime/damageableBrushFlow.test.mjs new file mode 100644 index 0000000..1fd9e91 --- /dev/null +++ b/test/runtime/damageableBrushFlow.test.mjs @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const damageableBrushFlow = await importTsModule("src/runtime/app/damageableBrushFlow.ts"); + +test("damageable brush damage reduces health until lethal", (t) => { + installWindowTimerStub(t); + const calls = []; + const { flow } = createFlow({ + entities: [ + quakeEntity({ + classname: "func_button", + health: 10, + index: 42, + target: "t1", + }), + ], + onActivateEntity: (entityIndex) => { + calls.push(["activateEntity", entityIndex]); + return false; + }, + onUseTargets: (entity) => { + calls.push(["useTargets", entity.index]); + return true; + }, + }); + + assert.equal(flow.damage(42, 4), true); + assert.deepEqual(flow.snapshot(), { + brushes: [{ entityIndex: 42, health: 6 }], + }); + assert.deepEqual(calls, []); +}); + +test("lethal damage to targeted func_button falls back to target chain when no mover activates", (t) => { + const timers = installWindowTimerStub(t); + const calls = []; + const { flow } = createFlow({ + entities: [ + quakeEntity({ + classname: "func_button", + health: 5, + index: 42, + target: "t1", + }), + ], + onActivateEntity: (entityIndex) => { + calls.push(["activateEntity", entityIndex]); + return false; + }, + onUseTargets: (entity) => { + calls.push(["useTargets", entity.index]); + return true; + }, + }); + + assert.equal(flow.damage(42, 5), true); + assert.deepEqual(calls, [ + ["activateEntity", 42], + ["useTargets", 42], + ]); + assert.equal(timers.pending().length, 1); +}); + +test("lethal damage to mover-backed func_button does not double-fire targets", (t) => { + installWindowTimerStub(t); + const calls = []; + const { flow } = createFlow({ + entities: [ + quakeEntity({ + classname: "func_button", + health: 5, + index: 42, + target: "t1", + }), + ], + onActivateEntity: (entityIndex) => { + calls.push(["activateEntity", entityIndex]); + return true; + }, + onUseTargets: (entity) => { + calls.push(["useTargets", entity.index]); + return true; + }, + }); + + assert.equal(flow.damage(42, 5), true); + assert.deepEqual(calls, [["activateEntity", 42]]); +}); + +function createFlow({ + entities, + onActivateEntity = () => false, + onUseTargets = () => false, +}) { + const entityByIndex = new Map(entities.map((entity) => [entity.index, entity])); + const disabled = new Set(); + const flow = damageableBrushFlow.createQuakeDamageableBrushFlow({ + activateEntity: onActivateEntity, + activateSecretTrigger: () => undefined, + disableEntity: (entityIndex) => disabled.add(entityIndex), + getEntity: (entityIndex) => entityByIndex.get(entityIndex), + isEntityDisabled: (entityIndex) => disabled.has(entityIndex), + isPaused: () => false, + pausedTimerPollMs: 100, + triggerOneShot: () => true, + useTargets: onUseTargets, + }); + flow.setup(entities.map((entity) => entity.index)); + return { disabled, flow }; +} + +function quakeEntity({ classname, health, index, target, wait }) { + return { + classname, + index, + origin: { x: 0, y: 0, z: 0 }, + properties: { + classname, + health, + ...(target ? { target } : {}), + ...(wait === undefined ? {} : { wait }), + }, + }; +} + +function installWindowTimerStub(t) { + const previousWindow = globalThis.window; + let nextTimerId = 1; + const timers = new Map(); + globalThis.window = { + setTimeout: (callback, delay) => { + const id = nextTimerId++; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeout: (id) => timers.delete(id), + }; + t.after(() => { + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + }); + return { + pending: () => [...timers.values()], + }; +} diff --git a/test/runtime/gameplayInputFlow.test.mjs b/test/runtime/gameplayInputFlow.test.mjs new file mode 100644 index 0000000..3cc1ab6 --- /dev/null +++ b/test/runtime/gameplayInputFlow.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { Window } from "happy-dom"; + +import { importTsModule } from "../importTsModule.mjs"; + +const gameplayInputFlow = await importTsModule("src/runtime/app/gameplayInputFlow.ts"); + +test("weapon digit key codes map to Quake weapon impulses", () => { + assert.equal(gameplayInputFlow.quakeWeaponImpulseForGameplayKeyCode("Digit1"), 1); + assert.equal(gameplayInputFlow.quakeWeaponImpulseForGameplayKeyCode("Digit8"), 8); + assert.equal(gameplayInputFlow.quakeWeaponImpulseForGameplayKeyCode("Digit9"), null); + assert.equal(gameplayInputFlow.quakeWeaponImpulseForGameplayKeyCode("Key1"), null); +}); + +test("weapon digit keydown dispatches an event-bound impulse", (t) => { + const window = installDomGlobals(t); + const impulses = []; + const flow = createInputFlow({ + changeWeaponByImpulse: (impulse) => { + impulses.push(impulse); + return true; + }, + }); + + assert.equal(flow.handleWeaponKey({ + code: "Digit7", + repeat: false, + target: window.document.body, + }), true); + assert.deepEqual(impulses, [7]); +}); + +test("weapon key handling ignores repeats, editable targets, and debug fly mode", (t) => { + const window = installDomGlobals(t); + const input = window.document.createElement("input"); + const impulses = []; + const flow = createInputFlow({ + changeWeaponByImpulse: (impulse) => { + impulses.push(impulse); + return true; + }, + debugFlyEnabled: () => false, + }); + const debugFlyFlow = createInputFlow({ + changeWeaponByImpulse: (impulse) => { + impulses.push(impulse); + return true; + }, + debugFlyEnabled: () => true, + }); + + assert.equal(flow.handleWeaponKey({ code: "Digit3", repeat: true, target: window.document.body }), false); + assert.equal(flow.handleWeaponKey({ code: "Digit3", repeat: false, target: input }), false); + assert.equal(debugFlyFlow.handleWeaponKey({ code: "Digit3", repeat: false, target: window.document.body }), false); + assert.deepEqual(impulses, []); +}); + +test("weapon digit keys prevent browser defaults outside editable controls", (t) => { + const window = installDomGlobals(t); + const input = window.document.createElement("input"); + const flow = createInputFlow(); + + assert.equal(flow.shouldPreventGameplayKeyDefault({ code: "Digit4", target: window.document.body }), true); + assert.equal(flow.shouldPreventGameplayKeyDefault({ code: "Digit4", target: input }), false); +}); + +function createInputFlow(overrides = {}) { + return gameplayInputFlow.createQuakeGameplayInputFlow({ + canUseGameplayInput: () => true, + changeWeaponByImpulse: () => true, + clearMobileLookInput: () => undefined, + clearMobileMoveInput: () => undefined, + debugFlyEnabled: () => false, + player: () => null, + ...overrides, + }); +} + +function installDomGlobals(t) { + const previousDocument = globalThis.document; + const previousHTMLElement = globalThis.HTMLElement; + const previousWindow = globalThis.window; + const window = new Window(); + globalThis.document = window.document; + globalThis.HTMLElement = window.HTMLElement; + globalThis.window = window; + t.after(() => { + if (previousDocument === undefined) { + delete globalThis.document; + } else { + globalThis.document = previousDocument; + } + if (previousHTMLElement === undefined) { + delete globalThis.HTMLElement; + } else { + globalThis.HTMLElement = previousHTMLElement; + } + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + }); + return window; +} diff --git a/test/runtime/moversZeroTravelButton.test.mjs b/test/runtime/moversZeroTravelButton.test.mjs new file mode 100644 index 0000000..cf3386c --- /dev/null +++ b/test/runtime/moversZeroTravelButton.test.mjs @@ -0,0 +1,175 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "../importTsModule.mjs"; + +const { createQuakeMoversController } = await importTsModule("src/runtime/movers.ts"); + +test("zero-travel func_button remains activatable and fires its target once", (t) => { + const clock = installManualRuntimeClock(t); + const firedTargets = []; + const controller = createQuakeMoversController({ + applyState: () => undefined, + fireTarget: (targetname, sourceEntityIndex) => { + firedTargets.push({ sourceEntityIndex, targetname }); + }, + groupUnlocked: () => true, + playerBlocks: () => false, + }); + const button = { + classname: "func_button", + index: 202, + model: "*40", + modelIndex: 40, + properties: { + angle: "-2", + classname: "func_button", + model: "*40", + target: "t81", + wait: "-1", + }, + }; + const model = { + faceCount: 0, + firstFace: 0, + headNodes: [0, 0, 0, 0], + hulls: [], + index: 40, + mins: { x: -253, y: -1023, z: 49 }, + maxs: { x: -191, y: -961, z: 53 }, + origin: { x: 0, y: 0, z: 0 }, + }; + + controller.setup([button], [model], { x: 0, y: 0, z: 0 }, null); + + assert.equal(controller.debugStats().movers.length, 1); + assert.equal(controller.activateEntity(button.index), true); + assert.deepEqual(firedTargets, [{ sourceEntityIndex: 202, targetname: "t81" }]); + assert.equal(controller.debugStats().movers[0].mode, "opening"); + + clock.advanceFrames(1, 100); + + assert.equal(controller.debugStats().movers[0].mode, "open"); + assert.equal(controller.debugStats().activeMoverCount, 0); +}); + +test("zero-travel func_button with finite wait can close and be reused", (t) => { + const clock = installManualRuntimeClock(t); + const firedTargets = []; + const controller = createQuakeMoversController({ + applyState: () => undefined, + fireTarget: (targetname, sourceEntityIndex) => { + firedTargets.push({ sourceEntityIndex, targetname }); + }, + groupUnlocked: () => true, + playerBlocks: () => false, + }); + const button = { + classname: "func_button", + index: 7, + model: "*7", + modelIndex: 7, + properties: { + classname: "func_button", + model: "*7", + target: "again", + wait: "0.1", + }, + }; + const model = { + faceCount: 0, + firstFace: 0, + headNodes: [0, 0, 0, 0], + hulls: [], + index: 7, + mins: { x: 0, y: 0, z: 0 }, + maxs: { x: 16, y: 16, z: 16 }, + origin: { x: 0, y: 0, z: 0 }, + }; + + controller.setup([button], [model], { x: 0, y: 0, z: 0 }, { + entities: [{ + entityIndex: button.index, + resolvedMover: zeroTravelButtonFact({ wait: 0.1 }), + }], + }); + + assert.equal(controller.activateEntity(button.index), true); + clock.advanceFrames(1, 16); + assert.equal(controller.debugStats().movers[0].mode, "open"); + clock.advanceFrames(1, 120); + assert.equal(controller.debugStats().movers[0].mode, "closing"); + clock.advanceFrames(1, 16); + assert.equal(controller.debugStats().movers[0].mode, "closed"); + assert.equal(controller.activateEntity(button.index), true); + assert.deepEqual(firedTargets, [ + { sourceEntityIndex: 7, targetname: "again" }, + { sourceEntityIndex: 7, targetname: "again" }, + ]); +}); + +function installManualRuntimeClock(t) { + const previousPerformance = globalThis.performance; + const previousWindow = globalThis.window; + let now = 0; + let nextRafId = 1; + const callbacks = new Map(); + const nativePerformance = globalThis.performance ?? {}; + globalThis.performance = { + ...nativePerformance, + now: () => now, + }; + globalThis.window = { + cancelAnimationFrame: (id) => { + callbacks.delete(id); + }, + requestAnimationFrame: (callback) => { + const id = nextRafId++; + callbacks.set(id, callback); + return id; + }, + }; + t.after(() => { + if (previousPerformance === undefined) { + delete globalThis.performance; + } else { + globalThis.performance = previousPerformance; + } + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + }); + return { + advanceFrames(count, stepMs) { + for (let frame = 0; frame < count; frame += 1) { + const pending = [...callbacks.entries()]; + callbacks.clear(); + if (!pending.length) return; + now += stepMs; + for (const [, callback] of pending) callback(now); + } + }, + }; +} + +function zeroTravelButtonFact({ wait }) { + return { + kind: "func_button", + source: { spawnFunction: "func_button" }, + speed: 40, + wait, + lip: 4, + sounds: 0, + damageable: false, + initialState: "bottom", + pos1Origin: { x: 0, y: 0, z: 0 }, + pos2Origin: { x: 0, y: 0, z: 0 }, + initialOrigin: { x: 0, y: 0, z: 0 }, + moveDirection: { x: 1, y: 0, z: 0 }, + travelDistance: 0, + travelOffset: { x: 0, y: 0, z: 0 }, + callbacks: { touch: "button_touch" }, + }; +}