diff --git a/src/App.ts b/src/App.ts index a286f82..cd01d3b 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1390,6 +1390,40 @@ const quakeEntityMeshes = createQuakeEntityMeshMountFlow({ sceneElement, schedulePresentationResync: (handle) => world.schedulePresentationResync(handle), }); + +function quakeShootablePrewarmLeavesAt(origin: [number, number, number]): Set | null { + const visibility = currentResult?.visibility; + const visibleLeaves = visibility?.visibleLeavesAt(origin) ?? null; + const metadata = visibility?.metadata; + if (!visibility || !metadata || !visibleLeaves) return visibleLeaves; + const leafIndex = visibility.leafIndexAt(origin); + const leaf = metadata.leaves[leafIndex]; + if (!leaf) return visibleLeaves; + const prewarmLeaves = new Set(visibleLeaves); + for (const adjacentLeafIndex of leaf.adjacentLeafIndexes) { + const adjacentLeaf = metadata.leaves[adjacentLeafIndex]; + if (!adjacentLeaf) continue; + prewarmLeaves.add(adjacentLeafIndex); + for (const visibleLeafIndex of adjacentLeaf.visibleLeafIndexes ?? []) { + prewarmLeaves.add(visibleLeafIndex); + } + } + for (const mover of movers.debugStats().movers) { + if (mover.kind === "button" || mover.mode === "closed") continue; + const modelIndex = entityByIndex.get(mover.entityIndex)?.modelIndex; + if (modelIndex === undefined) continue; + for (const moverLeaf of world.modelLeaves(modelIndex)) { + const leaf = metadata.leaves[moverLeaf.leafIndex]; + if (!leaf) continue; + prewarmLeaves.add(moverLeaf.leafIndex); + for (const visibleLeafIndex of leaf.visibleLeafIndexes ?? []) { + prewarmLeaves.add(visibleLeafIndex); + } + } + } + return prewarmLeaves; +} + const menu = createQuakeMenuController({ enabled: QUAKE_MENU_ENABLED, host, @@ -1750,11 +1784,19 @@ const shootables = createQuakeShootablesController({ onDestroyed: (entity) => { if (entity.classname.startsWith("monster_")) quakeLevelStats.markMonsterKilled(entity.index); }, + onExplosion: (event) => { + quakeImpactParticleFlow.spawnExplosion({ + flavor: event.flavor, + origin: event.origin, + radiusUnits: event.radiusUnits, + }); + }, pointToPoly: quakeCameraView.pointToPoly, shouldSpawn: shouldSpawnQuakeShootableForCurrentMode, pixelate: world.pixelate, schedulePresentationResync: world.schedulePresentationResync, visibleLeavesAt: world.visibleLeavesAt, + prewarmLeavesAt: quakeShootablePrewarmLeavesAt, fireTarget: fireQuakeTarget, playSound: (soundPath, options) => audio.playSound(soundPath, options), }); @@ -1883,6 +1925,13 @@ const weapons = createQuakeWeaponsController({ origin: event.origin, }); }, + onExplosionImpact: (event) => { + quakeImpactParticleFlow.spawnExplosion({ + flavor: event.flavor, + origin: event.origin, + radiusUnits: event.radiusUnits, + }); + }, onHit: () => quakeWeaponPresentation.flashCrosshairHit(), onWallImpact: (event) => { quakeImpactParticleFlow.spawnWallImpact({ @@ -2087,6 +2136,7 @@ quakeMoverInteractions = createQuakeMoverInteractionFlow({ ), shootables, showCenterPrint: (text) => quakeTextPresentation.centerPrint(text), + syncShootablesVisibility: (origin, force) => shootables.syncVisibility(origin, force), syncCrosshairTarget: syncQuakeCrosshairTarget, }); const quakeAssetWarmup = createQuakeAssetWarmupFlow({ @@ -2509,13 +2559,15 @@ function createNoopQuakeImpactParticleFlow(): QuakeImpactParticleFlow { dispose: () => undefined, setEnabled: () => undefined, spawnBlood: () => undefined, + spawnExplosion: () => undefined, spawnWallImpact: () => undefined, }; } function quakeWallImpactParticleCount(effect: QuakeWeaponWallImpactEffect): number { - if (effect === "spike") return 2; - return 3; + if (effect === "spike") return 4; + if (effect === "superspike") return 6; + return 5; } function setQuakeDebugShowMenuOption(visible: boolean): void { @@ -4406,6 +4458,11 @@ function syncPlayerCollision(): void { getPlayer().syncCollision(); } +function handleQuakeControlsChange(): void { + syncPlayerCollision(); + shootables.syncVisibility(controls.getOrigin()); +} + function disposeQuakeApp(): void { quakeAppDisposed = true; stopQuakeMultiplayerScene("app-dispose"); @@ -4440,7 +4497,7 @@ function disposeQuakeApp(): void { debugShowLabelsOption?.removeEventListener("change", quakeDebugPanelFlow.handleShowLabelsOptionChange); quakeDebugRecorder.dispose(); quakeDebugPanelFlow.stopStats(); - controls.removeEventListener("change", syncPlayerCollision); + controls.removeEventListener("change", handleQuakeControlsChange); controls.removeEventListener("end", quakeGameplayInput.clearCrouchInput); controls.destroy(); menu.dispose(); @@ -4621,7 +4678,7 @@ debugShowTexturesOption?.addEventListener("change", quakeDebugPanelFlow.handleSh debugFlyModeOption?.addEventListener("change", handleQuakeDebugFlyModeOptionChange); debugShowOutlinesOption?.addEventListener("change", quakeDebugPanelFlow.handleShowOutlinesOptionChange); debugShowLabelsOption?.addEventListener("change", quakeDebugPanelFlow.handleShowLabelsOptionChange); -controls.addEventListener("change", syncPlayerCollision); +controls.addEventListener("change", handleQuakeControlsChange); controls.addEventListener("end", quakeGameplayInput.clearCrouchInput); syncQuakeHud(); diff --git a/src/prepare/assets.mjs b/src/prepare/assets.mjs index 03fa49d..ff0f5a4 100644 --- a/src/prepare/assets.mjs +++ b/src/prepare/assets.mjs @@ -3381,9 +3381,12 @@ async function writeQuakeWeaponModelFiles(assets, sourceProgramFacts, renderBund } function buildQuakeWeaponModels(assets, sourceProgramFacts, renderBundleBuilder) { + const muzzleFlashModelPaths = quakePlayerWeaponMuzzleFlashViewModelPaths(sourceProgramFacts); return Promise.all( quakePlayerWeaponViewModelPaths(sourceProgramFacts) - .map((modelPath) => buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath)), + .map((modelPath) => buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath, { + muzzleFlash: muzzleFlashModelPaths.has(modelPath), + })), ); } @@ -3406,6 +3409,30 @@ function quakePlayerWeaponViewModelPaths(sourceProgramFacts) { return paths; } +function quakePlayerWeaponMuzzleFlashViewModelPaths(sourceProgramFacts) { + const paths = new Set(); + const profiles = sourceProgramFacts?.playerWeapons?.profiles; + if (!profiles || typeof profiles !== "object") return paths; + for (const profile of Object.values(profiles)) { + const modelPath = quakeNormalizedWeaponViewModelPath(profile?.presentation?.viewModelPath); + if (!modelPath || !quakeWeaponPresentationHasMuzzleFlash(profile?.presentation)) continue; + paths.add(modelPath); + } + return paths; +} + +function quakeWeaponPresentationHasMuzzleFlash(presentation) { + const variants = presentation?.fireAnimation?.variants; + return Array.isArray(variants) && variants.some((variant) => + Array.isArray(variant?.frames) && variant.frames.some((frame) => frame?.muzzleFlash === true), + ); +} + +function quakeNormalizedWeaponViewModelPath(value) { + const modelPath = typeof value === "string" ? value.trim().toLowerCase() : ""; + return /^progs\/v_[a-z0-9_]+\.mdl$/.test(modelPath) ? modelPath : ""; +} + function quakePlayerWeaponProjectileModelPaths(sourceProgramFacts) { const paths = []; const seen = new Set(); @@ -3442,7 +3469,12 @@ function quakeWeaponModelUrlMap(sourceProgramFacts) { return urls; } -async function buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath = QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH) { +async function buildQuakeWeaponModel( + assets, + renderBundleBuilder, + modelPath = QUAKE_DEFAULT_WEAPON_VIEWMODEL_PATH, + options = {}, +) { const model = parseQuakeAliasModel(assets, modelPath); const idleFrame = model.frames[0]; const fireFrame = model.frames[1] ?? idleFrame; @@ -3456,10 +3488,13 @@ async function buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath = QU brightness: textureBrightness, }); const polygons = model.triangles.map((triangle) => { - const uvs = triangle.indices.map((index) => quakeAliasUv(model, triangle, index)); - const isNozzle = isQuakeWeaponNozzlePolygon(uvs); + const indices = quakeAliasRenderBundleWindingOrder(triangle.indices); + const uvs = quakeAliasRenderBundleWindingOrder( + triangle.indices.map((index) => quakeAliasUv(model, triangle, index)), + ); + const isNozzle = options.muzzleFlash === true && isQuakeWeaponNozzlePolygon(uvs); const frame = isNozzle ? fireFrame : idleFrame; - const vertices = triangle.indices.map((index) => quakeWeaponVertex(frame.vertices[index])); + const vertices = indices.map((index) => quakeWeaponVertex(frame.vertices[index])); if (isNozzle) { return { vertices, @@ -3476,16 +3511,16 @@ async function buildQuakeWeaponModel(assets, renderBundleBuilder, modelPath = QU uvs, }; }); - const tightenWeaponDom = quakeDomTighteningEnabled("other", false); return { source: modelPath, renderBundle: await renderBundleBuilder.build({ bundleName: quakeWeaponRenderBundleName(modelPath), polygons: anchorQuakeWeaponPolygons(polygons), extractLeafStyles: true, - tightenAtlasLeaves: tightenWeaponDom, - optimizeAtlasLeafBasis: tightenWeaponDom, - optimizeAtlasLeafHomography: tightenWeaponDom, + // Viewmodel leaves are close to camera; atlas tightening can detach visible weapon faces. + tightenAtlasLeaves: false, + optimizeAtlasLeafBasis: false, + optimizeAtlasLeafHomography: false, }), }; } diff --git a/src/quake.css b/src/quake.css index d351ca6..b77bb85 100644 --- a/src/quake.css +++ b/src/quake.css @@ -244,16 +244,34 @@ body.quake-menu-open #quake-hud { .quake-impact-particle-dust-a, .quake-impact-particle-dust-b, .quake-impact-particle-dust-c { - border-radius: 1px; + border-radius: 0; } .quake-impact-particle-dust-a { - background: #444444; + background: #c8c8c8; } .quake-impact-particle-dust-b { - background: #444444; + background: #808080; } .quake-impact-particle-dust-c { - background: #444444; + background: #202020; +} +.quake-impact-particle-explosion-a, +.quake-impact-particle-explosion-b, +.quake-impact-particle-explosion-c, +.quake-impact-particle-explosion-d { + border-radius: 50%; +} +.quake-impact-particle-explosion-a { + background: #ffe06a; +} +.quake-impact-particle-explosion-b { + background: #f06416; +} +.quake-impact-particle-explosion-c { + background: #6b625a; +} +.quake-impact-particle-explosion-d { + background: #29231f; } body.quake-loading #quake-damage-overlay, body.quake-loading #quake-bonus-overlay, @@ -2263,7 +2281,8 @@ body.quake-debug-outlines #quake-debug-outline-legend { visibility: visible; } .polycss-mesh.shootable.quake-shootable-prewarmed { - visibility: hidden; + visibility: visible; + opacity: 0.001; pointer-events: none; transition: none; } @@ -2337,11 +2356,16 @@ body.quake-menu-unlocked #quake-weapon { } #quake-mobile-move-zone .joystick { position: absolute; - left: 72px; - top: 72px; + left: 50%; + top: 50%; + width: 108px; + height: 108px; + margin-left: -54px; + margin-top: -54px; display: block; z-index: 999; opacity: 0.58; + pointer-events: none; touch-action: none; user-select: none; transition: opacity 80ms linear; @@ -2350,20 +2374,21 @@ body.quake-menu-unlocked #quake-weapon { #quake-mobile-move-zone .joystick .front { position: absolute; display: block; - left: 0; - top: 0; + pointer-events: none; border-radius: 50%; } #quake-mobile-move-zone .joystick .back { + left: 0; + top: 0; width: 108px; height: 108px; - margin-left: -54px; - margin-top: -54px; background: rgba(10, 9, 7, 0.34); box-sizing: border-box; border: 2px solid rgba(245, 232, 200, 0.42); } #quake-mobile-move-zone .joystick .front { + left: 50%; + top: 50%; width: 54px; height: 54px; margin-left: -27px; @@ -2410,7 +2435,7 @@ body.quake-menu-unlocked #quake-weapon { background: rgba(74, 30, 14, 0.56); border-color: rgba(232, 154, 72, 0.86); } -@media (any-pointer: coarse) and (orientation: landscape), (max-width: 960px) and (orientation: landscape) { +@media (any-pointer: coarse), (max-width: 960px) { body:not(.quake-loading):not(.quake-menu-open):not(.quake-dead):not(.quake-level-complete) #quake-mobile-controls { display: block; } diff --git a/src/runtime/app/impactParticleFlow.ts b/src/runtime/app/impactParticleFlow.ts index 7325d74..8301ce7 100644 --- a/src/runtime/app/impactParticleFlow.ts +++ b/src/runtime/app/impactParticleFlow.ts @@ -2,9 +2,11 @@ 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_WALL_MAX_SPAWN = 7; +const QUAKE_IMPACT_PARTICLE_EXPLOSION_MAX_SPAWN = 8; const QUAKE_IMPACT_PARTICLE_BASE_COUNT = 3; -const QUAKE_IMPACT_PARTICLE_WALL_BASE_COUNT = 3; +const QUAKE_IMPACT_PARTICLE_WALL_BASE_COUNT = 5; +const QUAKE_IMPACT_PARTICLE_EXPLOSION_BASE_COUNT = 7; 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; @@ -12,6 +14,10 @@ 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_WALL_NEAR_SCALE = 1.78; +const QUAKE_IMPACT_PARTICLE_WALL_FAR_SCALE = 0.44; +const QUAKE_IMPACT_PARTICLE_EXPLOSION_NEAR_SCALE = 28; +const QUAKE_IMPACT_PARTICLE_EXPLOSION_FAR_SCALE = 10; const QUAKE_IMPACT_PARTICLE_DIRECTION_EPSILON = 0.08; const QUAKE_IMPACT_PARTICLE_CLASS = "quake-impact-particle"; const QUAKE_IMPACT_PARTICLE_BLOOD_COLORS = [ @@ -24,8 +30,19 @@ const QUAKE_IMPACT_PARTICLE_WALL_COLORS = [ "quake-impact-particle-dust-b", "quake-impact-particle-dust-c", ] as const; +const QUAKE_IMPACT_PARTICLE_WALL_SLOTS = [ + [-0.9, -0.35], + [0, -0.95], + [0.9, -0.35], + [-0.65, 0.25], + [0.65, 0.25], + [-0.35, 0.9], + [0.35, 0.9], +] as const; -type ImpactParticleKind = "blood" | "wall"; +type QuakeExplosionParticleFlavor = "explobox" | "grenade" | "lava" | "rocket"; +type ImpactParticleKind = "blood" | "explosion" | "wall"; +type ExplosionParticleRole = "debris" | "fire" | "flash"; export interface QuakeImpactParticleSpawn { count?: number; @@ -34,11 +51,17 @@ export interface QuakeImpactParticleSpawn { origin?: Vec3; } +export interface QuakeExplosionParticleSpawn extends QuakeImpactParticleSpawn { + flavor?: QuakeExplosionParticleFlavor; + radiusUnits?: number; +} + export interface QuakeImpactParticleFlow { clear(): void; dispose(): void; setEnabled(enabled: boolean): void; spawnBlood(input?: QuakeImpactParticleSpawn): void; + spawnExplosion(input?: QuakeExplosionParticleSpawn): void; spawnWallImpact(input?: QuakeImpactParticleSpawn): void; } @@ -58,7 +81,10 @@ interface ImpactParticle { dy: number; durationMs: number; element: HTMLElement; + fallY: number; rotationDeg: number; + scaleEnd: number; + scaleStart: number; shapeX: number; shapeY: number; size: number; @@ -88,7 +114,10 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp dy: 0, durationMs: 0, element, + fallY: 0, rotationDeg: 0, + scaleEnd: 0.65, + scaleStart: 1, shapeX: 1, shapeY: 1, size: 1, @@ -107,6 +136,10 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp spawnParticles("blood", input, resolveBloodParticleCount(input)); } + function spawnExplosion(input: QuakeExplosionParticleSpawn = {}): void { + spawnParticles("explosion", input, resolveExplosionParticleCount(input)); + } + function spawnWallImpact(input: QuakeImpactParticleSpawn = {}): void { spawnParticles("wall", input, resolveWallParticleCount(input)); } @@ -115,28 +148,35 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp if (!enabled || disposed || options.isGameplayPaused() || !options.canShow()) return; if (count <= 0) return; const startedAt = now(); - const distanceScale = particleDistanceScale(input.origin); + const distanceScale = particleDistanceScale(kind, input.origin); const damagePressure = particleDamagePressure(input.damage); + const explosionScale = kind === "explosion" + ? explosionFlavorScale((input as QuakeExplosionParticleSpawn).flavor) + : 1; 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); + const explosionRole = kind === "explosion" ? explosionParticleRole(index, count) : null; + const motion = particleMotion(kind, angle, spreadScale, index, count, explosionRole); + const colorClass = particleColorClass(kind, explosionRole); + const shape = particleShape(kind, damagePressure, explosionRole); + const scale = particleScaleEnvelope(kind, explosionRole); 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.durationMs = particleDuration(kind, damagePressure, explosionRole); + particle.x = motion.x; + particle.y = motion.y; + particle.dx = motion.dx; + particle.dy = motion.dy; + particle.fallY = motion.fallY; particle.rotationDeg = shape.rotationDeg; + particle.scaleEnd = scale.end; + particle.scaleStart = scale.start; particle.shapeX = shape.x; particle.shapeY = shape.y; - particle.size = particleSize(kind, distanceScale); + particle.size = particleSize(kind, distanceScale, explosionRole, explosionScale); particle.element.className = `${QUAKE_IMPACT_PARTICLE_CLASS} ${colorClass}`; particle.element.style.transform = particleTransform(particle, 0); particle.element.style.opacity = "1"; @@ -149,6 +189,8 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp particle.active = false; particle.element.style.opacity = "0"; particle.element.style.transform = "translate3d(0, 0, 0) scale(1, 1)"; + particle.scaleEnd = 0.65; + particle.scaleStart = 1; } cancelFrame(); } @@ -178,6 +220,18 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp return clampParticleCount(QUAKE_IMPACT_PARTICLE_WALL_BASE_COUNT, QUAKE_IMPACT_PARTICLE_WALL_MAX_SPAWN); } + function resolveExplosionParticleCount(input: QuakeExplosionParticleSpawn): number { + if (input.count !== undefined) { + return clampParticleCount(Math.floor(input.count), QUAKE_IMPACT_PARTICLE_EXPLOSION_MAX_SPAWN); + } + const flavorBoost = input.flavor === "explobox" || input.flavor === "lava" ? 1 : 0; + const radiusBoost = input.radiusUnits !== undefined && input.radiusUnits >= 120 ? 1 : 0; + return clampParticleCount( + QUAKE_IMPACT_PARTICLE_EXPLOSION_BASE_COUNT + flavorBoost + radiusBoost, + QUAKE_IMPACT_PARTICLE_EXPLOSION_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. @@ -224,36 +278,132 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp 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 particleMotion( + kind: ImpactParticleKind, + angle: number, + spreadScale: number, + index: number, + count: number, + explosionRole: ExplosionParticleRole | null, + ): { + dx: number; + dy: number; + fallY: number; + x: number; + y: number; + } { + if (kind === "wall") { + const slot = QUAKE_IMPACT_PARTICLE_WALL_SLOTS[index % QUAKE_IMPACT_PARTICLE_WALL_SLOTS.length]; + return { + x: (slot[0] * 16 + (Math.random() - 0.5) * 3) * spreadScale, + y: (slot[1] * 18 + (Math.random() - 0.5) * 3) * spreadScale, + dx: (Math.random() - 0.5) * 0.5 * spreadScale, + dy: Math.random() * 2 * spreadScale, + fallY: (8 + Math.random() * 18) * spreadScale, + }; + } + if (kind === "explosion") { + const jitterX = (Math.random() - 0.5) * 2 * spreadScale; + const jitterY = (Math.random() - 0.5) * 2 * spreadScale; + if (explosionRole === "flash") { + return { + x: jitterX * 0.25, + y: jitterY * 0.25, + dx: 0, + dy: -1.5 * spreadScale, + fallY: 0, + }; + } + if (explosionRole === "fire") { + return { + x: jitterX * 0.45, + y: jitterY * 0.45, + dx: (Math.random() - 0.5) * 1.5 * spreadScale, + dy: -2 * spreadScale, + fallY: 0, + }; + } + return { + x: jitterX, + y: jitterY, + dx: (Math.random() - 0.5) * 1.5 * spreadScale, + dy: 1 * spreadScale, + fallY: 0, + }; + } + const radius = (3 + Math.random() * 12) * spreadScale; + const speed = (20 + Math.random() * 28) * spreadScale; + return { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + dx: Math.cos(angle) * speed, + dy: Math.sin(angle) * speed, + fallY: 0, + }; } - function particleDuration(kind: ImpactParticleKind, damagePressure: number): number { - if (kind === "wall") return 120 + Math.random() * 100; + function particleDuration( + kind: ImpactParticleKind, + damagePressure: number, + explosionRole: ExplosionParticleRole | null, + ): number { + if (kind === "wall") return 260 + Math.random() * 160; + if (kind === "explosion") { + if (explosionRole === "flash") return 150 + damagePressure * 25 + Math.random() * 45; + if (explosionRole === "fire") return 190 + damagePressure * 35 + Math.random() * 55; + return 230 + damagePressure * 35 + Math.random() * 70; + } 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; + function particleColorClass(kind: ImpactParticleKind, explosionRole: ExplosionParticleRole | null): string { + if (kind === "explosion") { + if (explosionRole === "flash") return "quake-impact-particle-explosion-a"; + if (explosionRole === "fire") return "quake-impact-particle-explosion-b"; + return "quake-impact-particle-explosion-b"; + } + 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 particleSize( + kind: ImpactParticleKind, + distanceScale: number, + explosionRole: ExplosionParticleRole | null, + explosionScale: number, + ): number { + if (kind === "explosion") { + const roleScale = explosionRole === "flash" + ? 0.7 + : explosionRole === "fire" + ? 1.15 + : 1.55; + return distanceScale * explosionScale * roleScale * (1 + Math.random() * 0.08); + } + const variance = kind === "explosion" ? 0.42 : kind === "wall" ? 0.28 : 0.35; + return distanceScale * (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 }; + function explosionFlavorScale(flavor: QuakeExplosionParticleFlavor | undefined): number { + if (flavor === "explobox") return 1.28; + if (flavor === "lava") return 1.12; + if (flavor === "grenade") return 0.78; + return 1; + } + + function particleShape( + kind: ImpactParticleKind, + damagePressure: number, + explosionRole: ExplosionParticleRole | null, + ): { rotationDeg: number; x: number; y: number } { + if (kind === "wall") return { rotationDeg: 0, x: 1, y: 1 }; + if (kind === "explosion") { + if (explosionRole === "flash") return { rotationDeg: 0, x: 1, y: 1 }; + return { rotationDeg: Math.random() * 360, x: 1, y: 1 }; + } + if (Math.random() <= 0.62) return { rotationDeg: 0, x: 1, y: 1 }; const stretch = 1.08 + Math.random() * (0.16 + damagePressure * 0.12); return { rotationDeg: Math.random() * 360, @@ -262,6 +412,23 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp }; } + function particleScaleEnvelope( + kind: ImpactParticleKind, + explosionRole: ExplosionParticleRole | null, + ): { end: number; start: number } { + if (kind !== "explosion") return { end: 0.65, start: 1 }; + if (explosionRole === "flash") return { end: 1.55, start: 0.42 }; + if (explosionRole === "fire") return { end: 1.42, start: 0.5 }; + return { end: 1.28, start: 0.58 }; + } + + function explosionParticleRole(index: number, count: number): ExplosionParticleRole { + if (index < Math.max(1, count - 3)) return "debris"; + if (index < count - 1) return "fire"; + if (index === count - 1) return "flash"; + return "debris"; + } + function ensureFrame(): void { if (frameId !== null) return; frameId = requestQuakeAnimationFrame(tick); @@ -273,7 +440,7 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp frameId = null; } - function particleDistanceScale(origin?: Vec3): number { + function particleDistanceScale(kind: ImpactParticleKind, origin?: Vec3): number { const viewOrigin = options.viewOrigin?.(); if (!origin || !viewOrigin) return 1; const distance = Math.hypot( @@ -285,8 +452,17 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp (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; + const nearScale = kind === "explosion" + ? QUAKE_IMPACT_PARTICLE_EXPLOSION_NEAR_SCALE + : kind === "wall" + ? QUAKE_IMPACT_PARTICLE_WALL_NEAR_SCALE + : QUAKE_IMPACT_PARTICLE_NEAR_SCALE; + const farScale = kind === "explosion" + ? QUAKE_IMPACT_PARTICLE_EXPLOSION_FAR_SCALE + : kind === "wall" + ? QUAKE_IMPACT_PARTICLE_WALL_FAR_SCALE + : QUAKE_IMPACT_PARTICLE_FAR_SCALE; + return nearScale + (farScale - nearScale) * t; } function tick(at: number): void { @@ -317,14 +493,15 @@ export function createQuakeImpactParticleFlow(options: QuakeImpactParticleFlowOp dispose, setEnabled, spawnBlood, + spawnExplosion, 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 y = particle.y + particle.dy * t + particle.fallY * t * t; + const scale = particle.size * (particle.scaleStart + (particle.scaleEnd - particle.scaleStart) * t); const scaleX = scale * particle.shapeX; const scaleY = scale * particle.shapeY; return `translate3d(${x.toFixed(3)}px, ${y.toFixed(3)}px, 0) ` + diff --git a/src/runtime/app/moverInteractionFlow.ts b/src/runtime/app/moverInteractionFlow.ts index 9f028b0..6c54e09 100644 --- a/src/runtime/app/moverInteractionFlow.ts +++ b/src/runtime/app/moverInteractionFlow.ts @@ -36,6 +36,7 @@ export interface QuakeMoverInteractionFlowOptions { requiredDoorText(entityIndexes: number[], requiredKey: QuakeDoorKey): string | null; shootables: QuakeShootablesController; showCenterPrint(text: string): void; + syncShootablesVisibility(origin: [number, number, number], force?: boolean): void; syncCrosshairTarget(): void; } @@ -201,11 +202,23 @@ export function createQuakeMoverInteractionFlow(options: QuakeMoverInteractionFl if (state.kind === "button") options.applyButtonLeafVisual(leaf, quakeButtonIsPressed(state)); } if (carryPlayer) carryPlayerWithMover(state, delta); + if (shouldSyncShootablesAfterMoverApply(state, delta)) { + options.syncShootablesVisibility(options.playerOrigin(), true); + } state.lastOffset = [...state.offset] as Vec3; syncMoverSound(state, movePlayer); options.syncCrosshairTarget(); } + function shouldSyncShootablesAfterMoverApply(state: QuakeMoverState, delta: Vec3): boolean { + if (state.kind === "button") return false; + if (distanceSq3(delta, [0, 0, 0]) <= COLLISION_EPSILON) return false; + return state.kind === "door" || + state.kind === "secret-door" || + state.kind === "plat" || + state.kind === "train"; + } + function syncMoverSound(state: QuakeMoverState, activeUpdate: boolean): void { const previousMode = soundModes.get(state.entity.index); soundModes.set(state.entity.index, state.mode); diff --git a/src/runtime/debug/recording.ts b/src/runtime/debug/recording.ts index 6a588ec..a9bcdcc 100644 --- a/src/runtime/debug/recording.ts +++ b/src/runtime/debug/recording.ts @@ -105,6 +105,7 @@ export interface QuakeDebugRecordingCullingEventState { enemy: boolean; health: number; inPvs: boolean | null; + inPrewarmPvs: boolean | null; leafIndex: number | null; mounted: boolean; mountCandidate: boolean; @@ -116,6 +117,7 @@ export interface QuakeDebugRecordingCullingEventState { pendingAttackFireInMs: number | null; pendingAttackQuakecChain: string | null; prewarmed: boolean; + pvsSource: string; quakecChain: string | null; quakecIdealYaw: number | null; quakecMovementCall: string | null; @@ -443,6 +445,7 @@ function isCombatTraceMark(kind: string): boolean { kind.startsWith("player-quakec-") || kind === "shootable-damage" || kind === "shootable-destroy" || + kind === "shootables-visibility" || kind === "shootable-radius-damage" || kind === "shootable-radius-player-damage"; } @@ -551,6 +554,7 @@ function changedCullingFields( "enemy", "health", "inPvs", + "inPrewarmPvs", "leafIndex", "mounted", "mountCandidate", @@ -562,6 +566,7 @@ function changedCullingFields( "pendingAttackFireInMs", "pendingAttackQuakecChain", "prewarmed", + "pvsSource", "quakecChain", "quakecIdealYaw", "quakecMovementCall", @@ -602,6 +607,7 @@ function cullingEventState(entry: QuakeShootableDebugCullingEntry): QuakeDebugRe enemy: entry.enemy, health: entry.health, inPvs: entry.inPvs, + inPrewarmPvs: entry.inPrewarmPvs, leafIndex: entry.leafIndex, mounted: entry.mounted, mountCandidate: entry.mountCandidate, @@ -613,6 +619,7 @@ function cullingEventState(entry: QuakeShootableDebugCullingEntry): QuakeDebugRe pendingAttackFireInMs: entry.pendingAttackFireInMs, pendingAttackQuakecChain: entry.pendingAttackQuakecChain, prewarmed: entry.prewarmed, + pvsSource: entry.pvsSource, quakecChain: entry.quakecChain, quakecIdealYaw: entry.quakecIdealYaw, quakecMovementCall: entry.quakecMovementCall, diff --git a/src/runtime/mobileControls.ts b/src/runtime/mobileControls.ts index 36c4349..db2e9db 100644 --- a/src/runtime/mobileControls.ts +++ b/src/runtime/mobileControls.ts @@ -1,7 +1,7 @@ import { markQuakeTrace } from "./debug/traceMarks"; export const QUAKE_MOBILE_CONTROLS_QUERY = - "(any-pointer: coarse) and (orientation: landscape), (max-width: 960px) and (orientation: landscape)"; + "(any-pointer: coarse), (max-width: 960px)"; interface QuakeMobileControlsOptions { root: HTMLElement; @@ -39,6 +39,7 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): let root: HTMLElement | null = null; let moveZone: HTMLElement | null = null; let moveStick: HTMLElement | null = null; + let moveStickBack: HTMLElement | null = null; let moveStickFront: HTMLElement | null = null; let lookZone: HTMLElement | null = null; let fireButton: HTMLButtonElement | null = null; @@ -121,6 +122,7 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): lookZone = nextLookZone; moveZone = nextMoveZone; moveStick = nextMoveStick; + moveStickBack = nextMoveStickBack; moveStickFront = nextMoveStickFront; fireButton = nextFireButton; syncMoveStickVisual(0, 0, false); @@ -162,6 +164,7 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): lookZone = null; moveZone = null; moveStick = null; + moveStickBack = null; moveStickFront = null; fireButton = null; } @@ -438,18 +441,54 @@ export function createQuakeMobileControls(options: QuakeMobileControlsOptions): function syncMoveStickVisual(x: number, y: number, active: boolean): void { const stick = moveStick; + const back = moveStickBack; const front = moveStickFront; - if (!stick || !front) return; + if (!stick || !back || !front) return; const stickSize = 108; + const frontSize = 54; const frontTravel = stickSize / 4; stick.style.position = "absolute"; stick.style.display = "block"; - stick.style.left = "72px"; - stick.style.top = "72px"; + stick.style.left = "50%"; + stick.style.top = "50%"; + stick.style.width = `${stickSize}px`; + stick.style.height = `${stickSize}px`; + stick.style.marginLeft = `${-stickSize / 2}px`; + stick.style.marginTop = `${-stickSize / 2}px`; stick.style.opacity = active ? "1" : "0.58"; stick.style.touchAction = "none"; stick.style.userSelect = "none"; + stick.style.pointerEvents = "none"; stick.style.zIndex = "999"; + + back.style.position = "absolute"; + back.style.display = "block"; + back.style.left = "0px"; + back.style.top = "0px"; + back.style.width = `${stickSize}px`; + back.style.height = `${stickSize}px`; + back.style.marginLeft = "0px"; + back.style.marginTop = "0px"; + back.style.borderRadius = "50%"; + back.style.background = "rgba(10, 9, 7, 0.34)"; + back.style.boxSizing = "border-box"; + back.style.border = "2px solid rgba(245, 232, 200, 0.42)"; + back.style.pointerEvents = "none"; + + front.style.position = "absolute"; + front.style.display = "block"; + front.style.left = "50%"; + front.style.top = "50%"; + front.style.width = `${frontSize}px`; + front.style.height = `${frontSize}px`; + front.style.marginLeft = `${-frontSize / 2}px`; + front.style.marginTop = `${-frontSize / 2}px`; + front.style.borderRadius = "50%"; + front.style.background = "rgba(245, 232, 200, 0.18)"; + front.style.opacity = "0.5"; + front.style.boxSizing = "border-box"; + front.style.border = "2px solid rgba(245, 232, 200, 0.48)"; + front.style.pointerEvents = "none"; front.style.transform = `translate(${x * frontTravel}px, ${-y * frontTravel}px)`; } diff --git a/src/runtime/shootables.ts b/src/runtime/shootables.ts index b09c7ba..6703b73 100644 --- a/src/runtime/shootables.ts +++ b/src/runtime/shootables.ts @@ -104,6 +104,7 @@ import { quakeShootablesDebugStats, type QuakeShootablesDebugCullingSnapshot, type QuakeShootablesDebugStats, + type QuakeShootablesDebugVisibilitySyncSnapshot, } from "./shootables/debugStats"; import { quakecCanDamageAnyTracePointClear, @@ -301,6 +302,7 @@ export interface QuakeShootablesControllerOptions { contentsAt?(point: Vec3): number | null; dropBackpack?: (drop: QuakeMonsterBackpackDropRuntime) => boolean | void; onDestroyed?: (entity: QuakeEntity) => void; + onExplosion?(event: QuakeShootableExplosionEvent): void; enemyAnimationsEnabled?: () => boolean; enemiesFrozen?: () => boolean; enemyAttacksEnabled?: () => boolean; @@ -323,10 +325,19 @@ export interface QuakeShootablesControllerOptions { playerClearance?: QuakeShootablesPlayerClearanceOptions | null; schedulePresentationResync(handle: PolyMeshHandle): void; visibleLeavesAt(origin: [number, number, number]): Set | null; + prewarmLeavesAt?(origin: [number, number, number]): Set | null; fireTarget(targetname: string, sourceEntityIndex?: number): void; playSound?(soundPath: string, options?: QuakeShootableSoundOptions): boolean; } +export interface QuakeShootableExplosionEvent { + classname?: string; + entityIndex?: number; + flavor: "explobox" | "grenade" | "lava" | "rocket"; + origin: Vec3; + radiusUnits?: number; +} + export interface QuakeShootablesPlayerClearanceOptions { enemyClassnames?: readonly string[]; extraRadius: number; @@ -380,7 +391,10 @@ const QUAKE_MONSTER_SIGHT_ENTITY_WINDOW_SECONDS = 0.1; const QUAKE_MONSTER_AMBUSH_OR_ZOMBIE_CRUCIFIED_FLAGS = 3; const QUAKE_MONSTER_JUMP_GRAVITY = 800 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_MONSTER_DEATH_OUTPUT_CLASS = "quake-monster-death-output"; -const QUAKE_MONSTER_DEATH_OUTPUT_LIFETIME_MS = 4000; +const QUAKE_MONSTER_DEATH_OUTPUT_ARC_MAX_ACTIVE = 24; +const QUAKE_MONSTER_DEATH_OUTPUT_ARC_MAX_MS = 900; +const QUAKE_MONSTER_DEATH_OUTPUT_ARC_DT_CLAMP = 0.05; +const QUAKE_MONSTER_DEATH_OUTPUT_ARC_GRAVITY = 800 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_MONSTER_PATH_CORNER_HALF_EXTENT = 8 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_MONSTER_PATH_TOUCH_RADIUS = 24 * QUAKE_COLLISION_UNIT_SCALE; const QUAKE_CONTENTS_SOLID = -2; @@ -467,6 +481,7 @@ export function createQuakeShootablesController({ contentsAt, dropBackpack, onDestroyed, + onExplosion, enemyAnimationsEnabled, enemiesFrozen, enemyAttacksEnabled, @@ -489,12 +504,15 @@ export function createQuakeShootablesController({ playerClearance = null, schedulePresentationResync, visibleLeavesAt, + prewarmLeavesAt = visibleLeavesAt, fireTarget, playSound, }: QuakeShootablesControllerOptions): QuakeShootablesController { let shootables = createQuakeShootableStateMap(); let deathTimers: number[] = []; let deathOutputHandles: QuakeMonsterDeathOutputVisualHandle[] = []; + let deathOutputAnimationFrame: number | null = null; + const activeDeathOutputAnimations = new Set(); let destroyedEntityIndexes = new Set(); let currentModelLibrary: QuakePickupModelLibrary | null = null; let monsterPathCornersByTargetname = new Map(); @@ -505,6 +523,7 @@ export function createQuakeShootablesController({ const mountedEnemyAcquisitionVisibilityCache = createQuakeEnemyAcquisitionVisibilityCache(); let mountedEnemySightEntity: { entityIndex: number; seenAtSeconds: number } | null = null; let lastVisibilitySelectionKey = ""; + let lastVisibilitySync: QuakeShootablesDebugVisibilitySyncSnapshot | null = null; let lastMotionMaterialForward: Vec3 | null = null; let lastMotionMaterialOrigin: Vec3 | null = null; const prewarmQueues = createQuakeShootablePrewarmQueues({ @@ -596,6 +615,13 @@ export function createQuakeShootablesController({ randomRange: quakecRandomRange, schedulePresentationResync, traceLine: sourceTraceLine ? traceProjectileLine : undefined, + onExplosion: (event) => { + onExplosion?.({ + flavor: event.flavor, + origin: event.origin, + radiusUnits: event.radiusUnits, + }); + }, }); const enemyMovement = createQuakeEnemyMovementRuntime({ collisionEpsilon: QUAKE_SHOOTABLE_COLLISION_EPSILON, @@ -730,8 +756,12 @@ export function createQuakeShootablesController({ } function clearDeathOutputHandles(): void { + if (deathOutputAnimationFrame !== null) { + window.cancelAnimationFrame(deathOutputAnimationFrame); + deathOutputAnimationFrame = null; + } + activeDeathOutputAnimations.clear(); for (const output of deathOutputHandles) { - window.clearTimeout(output.timer); output.handle.remove(); } visibilityChurn.totalMeshHandlesRemoved += deathOutputHandles.length; @@ -1127,6 +1157,7 @@ export function createQuakeShootablesController({ shootable.dead = true; destroyedEntityIndexes.add(entityIndex); onDestroyed?.(shootable.entity); + emitShootableDeathExplosion(shootable); applyShootableDeathRadiusDamage(shootable, context); clearEnemyAttackState(shootable); const deathAnimationMs = deathState.playDeathAnimation(shootable, performance.now()); @@ -1246,6 +1277,26 @@ export function createQuakeShootablesController({ } } + function emitShootableDeathExplosion(shootable: QuakeShootableState): void { + const radiusDamage = quakeShootableDeathRadiusDamage(shootable.entity.classname); + if (!radiusDamage) return; + onExplosion?.({ + classname: shootable.entity.classname, + entityIndex: shootable.entity.index, + flavor: "explobox", + origin: shootableFloorOrigin(shootable), + radiusUnits: radiusDamage.radiusUnits, + }); + } + + function shootableFloorOrigin(shootable: QuakeShootableState): Vec3 { + return [ + shootable.origin[0], + shootable.origin[1], + shootable.origin[2] + shootable.bounds.min[2], + ]; + } + function applyShootableDeathRadiusDamage( source: QuakeShootableState, context: QuakeShootableDamageContext, @@ -1593,6 +1644,7 @@ export function createQuakeShootablesController({ distanceSq: number; inFrontOfCamera: boolean | null; inPvs: boolean | null; + inPrewarmPvs: boolean | null; lineOfSightTargetCount: number | null; mountCandidate: boolean; oversizedRenderVolume: boolean; @@ -1606,6 +1658,8 @@ export function createQuakeShootablesController({ withinUnmountDistance: boolean; }>(); const now = performance.now(); + const prewarmLeaves = prewarmLeavesAt(origin); + const prewarmExtraLeaves = prewarmExtraLeafIndexes(visibleLeaves, prewarmLeaves); for (const shootable of shootables.values()) { const oversizedRenderVolume = isOversizedShootableRenderVolume(shootable); @@ -1613,7 +1667,12 @@ export function createQuakeShootablesController({ shootable.leafIndex === undefined || visibleLeaves.has(shootable.leafIndex) || oversizedRenderVolume; + const prewarmLeaf = !prewarmLeaves || + shootable.leafIndex === undefined || + prewarmLeaves.has(shootable.leafIndex) || + oversizedRenderVolume; const inPvs = visibleLeaves ? pvsVisible : null; + const inPrewarmPvs = prewarmLeaves ? prewarmLeaf : null; const distanceSq = distanceSq3(origin, shootable.origin); const distance = Math.sqrt(distanceSq); const usingUnmountDistance = shootable.visible; @@ -1630,7 +1689,7 @@ export function createQuakeShootablesController({ const prewarmCandidate = !isPersistentCorpse && !shootable.dead && distanceSq <= QUAKE_SHOOTABLE_PREWARM_DISTANCE_SQ && - canPrewarmShootableForSelection(shootable, pvsVisible, origin); + canPrewarmShootableForSelection(shootable, prewarmLeaf, origin); inputs.set(shootable.entity.index, { canMount: mountDecision.canMount, canPrewarm, @@ -1638,6 +1697,7 @@ export function createQuakeShootablesController({ distanceSq, inFrontOfCamera: mountDecision.inFrontOfCamera, inPvs, + inPrewarmPvs, lineOfSightTargetCount: mountDecision.lineOfSightTargetCount, mountCandidate, oversizedRenderVolume, @@ -1675,6 +1735,11 @@ export function createQuakeShootablesController({ return { visibleLeafCount: visibleLeaves?.size ?? null, + prewarmLeafCount: prewarmLeaves?.size ?? null, + prewarmExtraLeafCount: prewarmExtraLeaves?.size ?? null, + visibleLeafIndexes: sortedOptionalLeafIndexes(visibleLeaves), + prewarmLeafIndexes: sortedOptionalLeafIndexes(prewarmLeaves), + prewarmExtraLeafIndexes: sortedOptionalLeafIndexes(prewarmExtraLeaves), limits: { mountDistance: QUAKE_SHOOTABLE_MOUNT_DISTANCE, unmountDistance: QUAKE_SHOOTABLE_UNMOUNT_DISTANCE, @@ -1691,6 +1756,7 @@ export function createQuakeShootablesController({ desiredPrewarmIndexes: sortedDebugIndexes(desiredPrewarmIndexes), candidateIndexes: candidates.concat(corpseCandidates).map((candidate) => candidate.index), prewarmCandidateIndexes: prewarmCandidates.map((candidate) => candidate.index), + lastVisibilitySync, entries: [...shootables.values()].map((shootable) => { const input = inputs.get(shootable.entity.index); const handleCount = countShootableHandles(shootable); @@ -1717,6 +1783,13 @@ export function createQuakeShootablesController({ mounted: handleCount > 0, prewarmed: handleCount > 0 && !shootable.visible, inPvs: input?.inPvs ?? null, + inPrewarmPvs: input?.inPrewarmPvs ?? null, + pvsSource: debugShootablePvsSource( + shootable, + visibleLeaves, + prewarmLeaves, + input?.oversizedRenderVolume ?? false, + ), oversizedRenderVolume: input?.oversizedRenderVolume ?? false, distance: input?.distance ?? 0, distanceSq: input?.distanceSq ?? 0, @@ -1884,6 +1957,37 @@ export function createQuakeShootablesController({ return [...indexes].sort((a, b) => a - b); } + function sortedOptionalLeafIndexes(indexes: Set | null): number[] | null { + return indexes ? sortedDebugIndexes(indexes) : null; + } + + function prewarmExtraLeafIndexes( + visibleLeaves: Set | null, + prewarmLeaves: Set | null, + ): Set | null { + if (!prewarmLeaves) return null; + if (!visibleLeaves) return new Set(prewarmLeaves); + const extra = new Set(); + for (const leafIndex of prewarmLeaves) { + if (!visibleLeaves.has(leafIndex)) extra.add(leafIndex); + } + return extra; + } + + function debugShootablePvsSource( + shootable: QuakeShootableState, + visibleLeaves: Set | null, + prewarmLeaves: Set | null, + oversizedRenderVolume: boolean, + ): "current" | "prewarm-extra" | "oversized" | "none" | "unknown" { + if (oversizedRenderVolume) return "oversized"; + if (shootable.leafIndex === undefined) return "unknown"; + if (!visibleLeaves) return "unknown"; + if (visibleLeaves.has(shootable.leafIndex)) return "current"; + if (prewarmLeaves?.has(shootable.leafIndex)) return "prewarm-extra"; + return "none"; + } + function debugSetOrigin(entityIndex: number, origin: Vec3): boolean { const shootable = shootables.get(entityIndex); if (!shootable) return false; @@ -1988,6 +2092,8 @@ export function createQuakeShootablesController({ const frameHandlesCreatedBefore = visibilityChurn.totalFrameHandlesCreated; const frameHandlesRemovedBefore = visibilityChurn.totalFrameHandlesRemoved; const visibleLeaves = visibleLeavesAt(origin); + const prewarmLeaves = prewarmLeavesAt(origin); + const prewarmExtraLeaves = prewarmExtraLeafIndexes(visibleLeaves, prewarmLeaves); const coarseCandidates: QuakeShootableVisibilityCandidate[] = []; const candidates: QuakeShootableVisibilityCandidate[] = []; const corpseCandidates: QuakeShootableVisibilityCandidate[] = []; @@ -1998,6 +2104,10 @@ export function createQuakeShootablesController({ shootable.leafIndex === undefined || visibleLeaves.has(shootable.leafIndex) || isOversizedShootableRenderVolume(shootable); + const prewarmLeaf = !prewarmLeaves || + shootable.leafIndex === undefined || + prewarmLeaves.has(shootable.leafIndex) || + isOversizedShootableRenderVolume(shootable); const distanceSq = distanceSq3(origin, shootable.origin); const maxDistanceSq = shootable.visible ? QUAKE_SHOOTABLE_UNMOUNT_DISTANCE_SQ : QUAKE_SHOOTABLE_MOUNT_DISTANCE_SQ; if (isPersistentShootableCorpse(shootable)) { @@ -2017,7 +2127,7 @@ export function createQuakeShootablesController({ } if ( distanceSq <= QUAKE_SHOOTABLE_PREWARM_DISTANCE_SQ && - canPrewarmShootableForSelection(shootable, visibleLeaf, origin) + canPrewarmShootableForSelection(shootable, prewarmLeaf, origin) ) { prewarmCandidates.push({ index: shootable.entity.index, distanceSq }); } @@ -2070,6 +2180,34 @@ export function createQuakeShootablesController({ const meshHandlesRemoved = visibilityChurn.totalMeshHandlesRemoved - meshHandlesRemovedBefore; const frameHandlesCreated = visibilityChurn.totalFrameHandlesCreated - frameHandlesCreatedBefore; const frameHandlesRemoved = visibilityChurn.totalFrameHandlesRemoved - frameHandlesRemovedBefore; + lastVisibilitySync = { + atMs: startedAt, + force, + origin: [origin[0], origin[1], origin[2]], + visibleLeafCount: visibleLeaves?.size ?? null, + prewarmLeafCount: prewarmLeaves?.size ?? null, + prewarmExtraLeafCount: prewarmExtraLeaves?.size ?? null, + visibleLeafIndexes: sortedOptionalLeafIndexes(visibleLeaves), + prewarmLeafIndexes: sortedOptionalLeafIndexes(prewarmLeaves), + prewarmExtraLeafIndexes: sortedOptionalLeafIndexes(prewarmExtraLeaves), + candidateIndexes: [...candidates, ...corpseCandidates].map((candidate) => candidate.index), + corpseCandidateIndexes: corpseCandidates.map((candidate) => candidate.index), + prewarmCandidateIndexes: prewarmCandidates.map((candidate) => candidate.index), + desiredMountedIndexes: sortedDebugIndexes(mountedIndexes), + desiredPrewarmIndexes: sortedDebugIndexes(prewarmedIndexes), + beforeMountedIndexes: sortedDebugIndexes(before.mountedIndexes), + beforeVisibleIndexes: sortedDebugIndexes(before.visibleIndexes), + beforePrewarmedIndexes: sortedDebugIndexes(before.prewarmedIndexes), + afterMountedIndexes: sortedDebugIndexes(after.mountedIndexes), + afterVisibleIndexes: sortedDebugIndexes(after.visibleIndexes), + afterPrewarmedIndexes: sortedDebugIndexes(after.prewarmedIndexes), + selectionChanged, + selectionApplied: selectionNeedsApply, + meshHandlesCreated, + meshHandlesRemoved, + frameHandlesCreated, + frameHandlesRemoved, + }; recordQuakeShootablesVisibilitySync(visibilityChurn, startedAt, { force, selectionChanged, @@ -2090,6 +2228,12 @@ export function createQuakeShootablesController({ selectionChanged, candidates: candidates.length, corpseCandidates: corpseCandidates.length, + visibleLeafCount: visibleLeaves?.size ?? -1, + prewarmLeafCount: prewarmLeaves?.size ?? -1, + prewarmExtraLeafCount: prewarmExtraLeaves?.size ?? -1, + desiredMountedKey: sortedDebugIndexes(mountedIndexes).join(","), + desiredPrewarmKey: sortedDebugIndexes(prewarmedIndexes).join(","), + afterVisibleKey: sortedDebugIndexes(after.visibleIndexes).join(","), desiredMounted: mountedIndexes.size, desiredPrewarm: prewarmedIndexes.size, visibleEnemies: after.visibleEnemies, @@ -2140,14 +2284,22 @@ export function createQuakeShootablesController({ if (!shootable.handle) { if (!mounted) { if (!canPrewarmHandle) return; - prewarmQueues.scheduleShootable(shootable); - return; + if (!shouldMountShootablePrewarmImmediately(shootable)) { + prewarmQueues.scheduleShootable(shootable); + return; + } + mountShootableHandle(shootable); + } else { + mountShootableHandle(shootable); } - mountShootableHandle(shootable); } setShootableVisible(shootable, mounted || deathAnimating); } + function shouldMountShootablePrewarmImmediately(shootable: QuakeShootableState): boolean { + return shootable.enemy !== undefined && !shootable.dead; + } + function mountShootableHandle(shootable: QuakeShootableState): void { initializeEnemyAnimation(shootable, performance.now()); if (canUseShootableAnimationFrameSet(shootable)) { @@ -2225,9 +2377,12 @@ export function createQuakeShootablesController({ if (!canCoarselyMountShootableHandle(shootable, playerOrigin)) return false; const visibleTargets = shootableMountVisibilityTargets(shootable).filter((target) => isInPlayerView(target)); if (isOversizedShootableRenderVolume(shootable)) return true; + const lineOfSight = shootable.handle && !shootable.visible + ? unbudgetedLineOfSight + : budgetedLineOfSight; let lineOfSightDeferred = false; for (const target of visibleTargets) { - const result = budgetedLineOfSight(playerOrigin, target); + const result = lineOfSight(playerOrigin, target); if (result === "clear") return true; if (result === "deferred") lineOfSightDeferred = true; } @@ -2611,13 +2766,16 @@ export function createQuakeShootablesController({ const shouldWalk = enemyMovement.shouldAnimateChasingEnemy(shootable, movementTarget, profile, canSeePlayer); if (shouldWalk) updateEnemyAnimation(shootable, "walk", now); const moved = enemyMovement.moveChasingEnemy(shootable, movementTarget, profile, dt, now, canSeePlayer); + const handledMovementStep = enemy.quakecMovementHandledStep; if (moved) applyEnemyMonsterJumpTriggers(shootable); - if (!shouldWalk) updateEnemyAnimation(shootable, moved ? "walk" : "idle", now); + if (!shouldWalk || (shouldWalk && !moved && !handledMovementStep)) { + updateEnemyAnimation(shootable, moved ? "walk" : "idle", now); + } enemyEye = shootableEyeOrigin(shootable); if (attacksEnabled && !attackBeforeMove && shouldAttemptEnemyAttack(canSeePlayer, shootable, enemy, now)) { if (tryStartEnemyAttack(shootable, enemy, enemyEye, attackTargetOrigin, profile, now, attackTarget)) return; } - if (enemy.quakecMovementHandledStep || (enemy.quakecRunner && shouldWalk)) { + if (handledMovementStep || (enemy.quakecRunner && moved)) { syncShootableTransform(shootable); } else { enemyMovement.faceShootableAtOrigin(shootable, movementTarget); @@ -2855,16 +3013,14 @@ export function createQuakeShootablesController({ movementCall: "ai_walk", stopDistance: 0, }); + const handledMovementStep = enemy.quakecMovementHandledStep; if (moved) applyEnemyMonsterJumpTriggers(shootable); - if (enemy.quakecMovementHandledStep || enemy.quakecRunner) { + if (handledMovementStep || (enemy.quakecRunner && moved)) { syncShootableTransform(shootable); } else { enemyMovement.faceShootableAtOrigin(shootable, target.origin); } - if ( - moved || - enemyMovement.shouldAnimateMovingEnemy(shootable, target.origin, QUAKE_MONSTER_PATH_TOUCH_RADIUS, COLLISION_EPSILON) - ) { + if (moved || handledMovementStep) { updateEnemyAnimation(shootable, "path", now); } else { updateEnemyAnimation(shootable, "idle", now); @@ -3658,6 +3814,7 @@ export function createQuakeShootablesController({ ...gib.gibModelPaths.map((path) => ({ kind: "gib", path })), ]; const count = Math.max(1, pieces.length); + const floorZ = shootable.origin[2] + shootable.collisionBounds.min[2]; for (const [index, item] of pieces.entries()) { const model = currentModelLibrary.models[item.path]; if (!model) continue; @@ -3666,22 +3823,20 @@ export function createQuakeShootablesController({ const origin: Vec3 = [ shootable.origin[0] + Math.cos(angle) * radius, shootable.origin[1] + Math.sin(angle) * radius, - shootable.origin[2] + (item.kind === "head" ? 0.65 : 0.28 + (index % 2) * 0.08), + floorZ - model.bounds.min[2], ]; - const handle = addMonsterDeathOutputMesh(shootable, model, origin, shootable.yaw + index * 37, item.kind); + const yaw = shootable.yaw + index * 37; + const handle = addMonsterDeathOutputMesh(shootable, model, origin, yaw, item.kind); if (!handle) continue; - const output: QuakeMonsterDeathOutputVisualHandle = { - handle, - timer: 0, - }; - const timer = window.setTimeout(() => { - output.handle.remove(); - visibilityChurn.totalMeshHandlesRemoved++; - deathOutputHandles = deathOutputHandles.filter((entry) => entry !== output); - }, QUAKE_MONSTER_DEATH_OUTPUT_LIFETIME_MS); - output.timer = timer; + const output: QuakeMonsterDeathOutputVisualHandle = { handle }; + const animation = monsterDeathOutputArcAnimation(model, origin, angle, index, item.kind, yaw); + if (animation && activeDeathOutputAnimations.size < QUAKE_MONSTER_DEATH_OUTPUT_ARC_MAX_ACTIVE) { + output.animation = animation; + activeDeathOutputAnimations.add(output); + } deathOutputHandles.push(output); } + scheduleDeathOutputAnimationFrame(); } function addMonsterDeathOutputMesh( @@ -3710,6 +3865,72 @@ export function createQuakeShootablesController({ return handle; } + function monsterDeathOutputArcAnimation( + model: QuakePickupModel, + origin: Vec3, + angle: number, + index: number, + kind: string, + yaw: number, + ): NonNullable | null { + if (typeof window.requestAnimationFrame !== "function") return null; + const horizontalSpeed = (kind === "head" ? 70 : 95 + (index % 3) * 18) * QUAKE_COLLISION_UNIT_SCALE; + const verticalSpeed = (kind === "head" ? 190 : 150 + (index % 2) * 35) * QUAKE_COLLISION_UNIT_SCALE; + return { + elapsedMs: 0, + lastAt: 0, + landingZ: origin[2], + position: [...origin] as Vec3, + renderYaw: normalizeShootableYaw(yaw, true), + scale: model.renderScale ? 1 / model.renderScale : 1, + velocity: [ + Math.cos(angle) * horizontalSpeed, + Math.sin(angle) * horizontalSpeed, + verticalSpeed, + ], + }; + } + + function scheduleDeathOutputAnimationFrame(): void { + if (deathOutputAnimationFrame !== null || activeDeathOutputAnimations.size === 0) return; + deathOutputAnimationFrame = window.requestAnimationFrame(tickDeathOutputAnimations); + } + + function tickDeathOutputAnimations(frameNow: number): void { + deathOutputAnimationFrame = null; + const now = Number.isFinite(frameNow) ? frameNow : performance.now(); + for (const output of [...activeDeathOutputAnimations]) { + const animation = output.animation; + if (!animation) { + activeDeathOutputAnimations.delete(output); + continue; + } + const dt = Math.min( + QUAKE_MONSTER_DEATH_OUTPUT_ARC_DT_CLAMP, + animation.lastAt ? Math.max(0, (now - animation.lastAt) / 1000) : 0.0167, + ); + animation.lastAt = now; + animation.elapsedMs += dt * 1000; + animation.velocity[2] -= QUAKE_MONSTER_DEATH_OUTPUT_ARC_GRAVITY * dt; + animation.position = [ + animation.position[0] + animation.velocity[0] * dt, + animation.position[1] + animation.velocity[1] * dt, + animation.position[2] + animation.velocity[2] * dt, + ]; + if (animation.position[2] <= animation.landingZ || animation.elapsedMs >= QUAKE_MONSTER_DEATH_OUTPUT_ARC_MAX_MS) { + animation.position = [animation.position[0], animation.position[1], animation.landingZ]; + activeDeathOutputAnimations.delete(output); + delete output.animation; + } + output.handle.setTransform({ + position: animation.position, + rotation: [0, 0, animation.renderYaw], + scale: animation.scale, + }); + } + scheduleDeathOutputAnimationFrame(); + } + function isPersistentShootableCorpse(shootable: QuakeShootableState): boolean { if (quakeBossScriptedLifecycle(shootable.entity.classname)) return false; return deathState.isPersistentCorpse(shootable); diff --git a/src/runtime/shootables/debugStats.ts b/src/runtime/shootables/debugStats.ts index dbcc535..cf3e7cc 100644 --- a/src/runtime/shootables/debugStats.ts +++ b/src/runtime/shootables/debugStats.ts @@ -40,6 +40,8 @@ export interface QuakeShootableDebugCullingEntry { mounted: boolean; prewarmed: boolean; inPvs: boolean | null; + inPrewarmPvs: boolean | null; + pvsSource: "current" | "prewarm-extra" | "oversized" | "none" | "unknown"; oversizedRenderVolume: boolean; distance: number; distanceSq: number; @@ -102,8 +104,42 @@ export interface QuakeShootableDebugMoveGoalDecision { kind: string; } +export interface QuakeShootablesDebugVisibilitySyncSnapshot { + atMs: number; + force: boolean; + origin: [number, number, number]; + visibleLeafCount: number | null; + prewarmLeafCount: number | null; + prewarmExtraLeafCount: number | null; + visibleLeafIndexes: number[] | null; + prewarmLeafIndexes: number[] | null; + prewarmExtraLeafIndexes: number[] | null; + candidateIndexes: number[]; + corpseCandidateIndexes: number[]; + prewarmCandidateIndexes: number[]; + desiredMountedIndexes: number[]; + desiredPrewarmIndexes: number[]; + beforeMountedIndexes: number[]; + beforeVisibleIndexes: number[]; + beforePrewarmedIndexes: number[]; + afterMountedIndexes: number[]; + afterVisibleIndexes: number[]; + afterPrewarmedIndexes: number[]; + selectionChanged: boolean; + selectionApplied: boolean; + meshHandlesCreated: number; + meshHandlesRemoved: number; + frameHandlesCreated: number; + frameHandlesRemoved: number; +} + export interface QuakeShootablesDebugCullingSnapshot { visibleLeafCount: number | null; + prewarmLeafCount: number | null; + prewarmExtraLeafCount: number | null; + visibleLeafIndexes: number[] | null; + prewarmLeafIndexes: number[] | null; + prewarmExtraLeafIndexes: number[] | null; limits: { mountDistance: number; unmountDistance: number; @@ -120,6 +156,7 @@ export interface QuakeShootablesDebugCullingSnapshot { desiredPrewarmIndexes: number[]; candidateIndexes: number[]; prewarmCandidateIndexes: number[]; + lastVisibilitySync: QuakeShootablesDebugVisibilitySyncSnapshot | null; entries: QuakeShootableDebugCullingEntry[]; } diff --git a/src/runtime/shootables/enemyProjectiles.ts b/src/runtime/shootables/enemyProjectiles.ts index f826be1..5afd3a7 100644 --- a/src/runtime/shootables/enemyProjectiles.ts +++ b/src/runtime/shootables/enemyProjectiles.ts @@ -59,6 +59,7 @@ export interface QuakeEnemyProjectileRuntimeOptions { damagePlayer(amount: number, context?: QuakePlayerDamageContext): boolean; hasLineOfSight(start: Vec3, end: Vec3): boolean; markTrace(kind: string, details?: QuakeEnemyProjectileTraceDetails): void; + onExplosion?(event: QuakeEnemyProjectileExplosionEvent): void; offsetPoint( origin: Vec3, start: Vec3, @@ -74,6 +75,14 @@ export interface QuakeEnemyProjectileRuntimeOptions { traceLine?(start: Vec3, end: Vec3): QuakeEnemyProjectileWorldTrace | null; } +export interface QuakeEnemyProjectileExplosionEvent { + flavor: "grenade" | "lava" | "rocket"; + origin: Vec3; + projectile: string; + radiusUnits?: number; + sourceEntityIndex?: number; +} + export interface QuakeEnemyProjectilePlayerPainRandomDetails { damage: number; projectile: string; @@ -254,6 +263,7 @@ export function createQuakeEnemyProjectileRuntime( recordProjectileDebugEvent("expire", projectileDebugEventPayload(projectile)); if (projectile.profile.projectileSplashOnExpire) { applySplashDamage(projectile, projectile.origin, playerOrigin, now, "expire"); + emitProjectileExplosion(projectile, projectile.origin); playProjectileSound(projectileExplosionSound(projectile.profile), projectile); } recordProjectileDebugEvent("remove", projectileDebugEventPayload(projectile)); @@ -289,6 +299,7 @@ export function createQuakeEnemyProjectileRuntime( if (hit.hit) { if (projectile.profile.projectileSplashDamage && projectile.profile.projectileSplashRadius) { applySplashDamage(projectile, hit.hitPoint, playerOrigin, now, "hit"); + emitProjectileExplosion(projectile, hit.hitPoint); playProjectileSound(projectileExplosionSound(projectile.profile), projectile); } else { const died = options.damagePlayer(projectile.damage, { inflictorOrigin: hit.hitPoint }); @@ -379,6 +390,7 @@ export function createQuakeEnemyProjectileRuntime( return true; } applySplashDamage(projectile, trace.end, playerOrigin, now, "blocked"); + emitProjectileExplosion(projectile, trace.end); playProjectileSound(projectileWorldTouchSound(projectile.profile), projectile); options.markTrace("enemy-projectile-blocked", { damage: projectile.damage, @@ -397,6 +409,24 @@ export function createQuakeEnemyProjectileRuntime( return false; } + function emitProjectileExplosion(projectile: QuakeEnemyProjectile, origin: Vec3): void { + if (!projectile.profile.projectileSplashDamage || !projectile.profile.projectileSplashRadius) return; + const projectileClassname = projectile.profile.projectileClassname ?? "enemy_projectile_magic"; + options.onExplosion?.({ + flavor: enemyProjectileExplosionFlavor(projectileClassname), + origin: [...origin] as Vec3, + projectile: projectileClassname, + radiusUnits: projectile.profile.projectileSplashRadius / QUAKE_COLLISION_UNIT_SCALE, + sourceEntityIndex: projectile.sourceEntityIndex, + }); + } + + function enemyProjectileExplosionFlavor(projectileClassname: string): QuakeEnemyProjectileExplosionEvent["flavor"] { + if (projectileClassname === "enemy_projectile_lavaball") return "lava"; + if (projectileClassname === "enemy_projectile_grenade") return "grenade"; + return "rocket"; + } + function bounceProjectile( projectile: QuakeEnemyProjectile, trace: QuakeEnemyProjectileWorldTrace, diff --git a/src/runtime/shootables/state.ts b/src/runtime/shootables/state.ts index 4ee8539..88e3ffd 100644 --- a/src/runtime/shootables/state.ts +++ b/src/runtime/shootables/state.ts @@ -203,8 +203,18 @@ export interface QuakeEnemyProjectile { } export interface QuakeMonsterDeathOutputVisualHandle { + animation?: QuakeMonsterDeathOutputAnimation; handle: PolyMeshHandle; - timer: number; +} + +export interface QuakeMonsterDeathOutputAnimation { + elapsedMs: number; + lastAt: number; + landingZ: number; + position: Vec3; + renderYaw: number; + scale: number; + velocity: Vec3; } export interface QuakeDamageTraceResult { diff --git a/src/runtime/weapons.ts b/src/runtime/weapons.ts index b5da97a..efffaf6 100644 --- a/src/runtime/weapons.ts +++ b/src/runtime/weapons.ts @@ -90,6 +90,13 @@ export interface QuakeWeaponWallImpactEvent { weapon: QuakeWeaponId; } +export interface QuakeWeaponExplosionImpactEvent { + flavor: "grenade" | "rocket"; + origin: Vec3; + radiusUnits?: number; + weapon: QuakeWeaponId; +} + export interface QuakeWeaponsControllerOptions { scene: PolySceneHandle; controls: Pick; @@ -115,6 +122,7 @@ export interface QuakeWeaponsControllerOptions { damageMultiplier?: () => number; random?: () => number; onDamageImpact?(event: QuakeWeaponDamageImpactEvent): void; + onExplosionImpact?(event: QuakeWeaponExplosionImpactEvent): void; onFire?(event: QuakeWeaponFireEvent): void; onWallImpact?(event: QuakeWeaponWallImpactEvent): void; onHit(): void; @@ -722,6 +730,7 @@ export function createQuakeWeaponsController({ damageMultiplier, random = Math.random, onDamageImpact, + onExplosionImpact, onFire, onWallImpact, onHit, @@ -1349,6 +1358,7 @@ export function createQuakeWeaponsController({ } if (hit) onHit(); projectile.origin = projectileImpactPresentationOrigin(projectile, trace); + emitWeaponExplosionImpact(projectile); recordProjectileDebugEvent("impact", { ...projectileDebugEventPayload(projectile), impactResult: "remove", @@ -1390,6 +1400,25 @@ export function createQuakeWeaponsController({ recordProjectileDebugEvent("expire", projectileDebugEventPayload(projectile)); if (!projectile.profile.explodeOnExpire) return; if (damageProjectileSplash(projectile.origin, projectile.profile, undefined)) onHit(); + emitWeaponExplosionImpact(projectile); + } + + function emitWeaponExplosionImpact(projectile: QuakeWeaponProjectile): void { + if (!projectile.profile.splashDamage || !projectile.profile.splashRadius) return; + const flavor = weaponExplosionFlavor(projectile.profile.weapon); + if (!flavor) return; + onExplosionImpact?.({ + flavor, + origin: [...projectile.origin] as Vec3, + radiusUnits: projectile.profile.splashRadius / QUAKE_COLLISION_UNIT_SCALE, + weapon: projectile.profile.weapon, + }); + } + + function weaponExplosionFlavor(weapon: QuakeWeaponId): QuakeWeaponExplosionImpactEvent["flavor"] | null { + if (weapon === "grenadelauncher") return "grenade"; + if (weapon === "rocketlauncher") return "rocket"; + return null; } function projectileDebugEventPayload(projectile: QuakeWeaponProjectile): Omit { diff --git a/test/enemyProjectiles.test.mjs b/test/enemyProjectiles.test.mjs index 5763e28..f74b4d5 100644 --- a/test/enemyProjectiles.test.mjs +++ b/test/enemyProjectiles.test.mjs @@ -13,6 +13,7 @@ function createRuntime({ traceLine = true, traceNormal = [-1, 0, 0], } = {}) { + const explosions = []; const sounds = []; let traceCount = 0; const runtime = createQuakeEnemyProjectileRuntime({ @@ -31,6 +32,7 @@ function createRuntime({ damagePlayer, hasLineOfSight: () => true, markTrace: () => undefined, + onExplosion: (event) => { explosions.push(event); }, offsetPoint: (origin) => [...origin], pixelate: () => undefined, playerDamageBounds: (origin) => ({ @@ -56,7 +58,7 @@ function createRuntime({ } : undefined, }); - return { runtime, sounds }; + return { explosions, runtime, sounds }; } function spawnProjectile(runtime, profile) { @@ -83,7 +85,7 @@ function spawnProjectile(runtime, profile) { } test("ogre grenades play source launch and bounce sounds", () => { - const { runtime, sounds } = createRuntime(); + const { explosions, runtime, sounds } = createRuntime(); spawnProjectile(runtime, { projectileClassname: "enemy_projectile_grenade", @@ -95,10 +97,11 @@ test("ogre grenades play source launch and bounce sounds", () => { "weapons/grenade.wav", "weapons/bounce.wav", ]); + assert.deepEqual(explosions, []); }); test("ogre grenades play source explosion sound on player splash hit", () => { - const { runtime, sounds } = createRuntime({ traceLine: false }); + const { explosions, runtime, sounds } = createRuntime({ traceLine: false }); spawnProjectile(runtime, { projectileClassname: "enemy_projectile_grenade", @@ -111,10 +114,16 @@ test("ogre grenades play source explosion sound on player splash hit", () => { "weapons/grenade.wav", "weapons/r_exp3.wav", ]); + assert.equal(explosions.length, 1); + assert.equal(explosions[0].flavor, "grenade"); + assert.deepEqual(explosions[0].origin, [0.4, 0, 0]); + assert.equal(explosions[0].projectile, "enemy_projectile_grenade"); + assert.equal(explosions[0].radiusUnits > 0, true); + assert.equal(explosions[0].sourceEntityIndex, 1); }); test("ogre grenades play source explosion sound on timeout", () => { - const { runtime, sounds } = createRuntime({ traceLine: false }); + const { explosions, runtime, sounds } = createRuntime({ traceLine: false }); spawnProjectile(runtime, { projectileClassname: "enemy_projectile_grenade", @@ -129,6 +138,10 @@ test("ogre grenades play source explosion sound on timeout", () => { "weapons/grenade.wav", "weapons/r_exp3.wav", ]); + assert.equal(explosions.length, 1); + assert.equal(explosions[0].flavor, "grenade"); + assert.deepEqual(explosions[0].origin, [0, 0, 0]); + assert.equal(explosions[0].projectile, "enemy_projectile_grenade"); }); test("zombie projectiles play source launch and miss sounds on world stop", () => { diff --git a/test/impactParticleFlow.test.mjs b/test/impactParticleFlow.test.mjs index fc51946..1de570e 100644 --- a/test/impactParticleFlow.test.mjs +++ b/test/impactParticleFlow.test.mjs @@ -89,8 +89,68 @@ test("impact particles use a fixed b-quad pool and do not allocate during spawn" 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].filter((element) => element.style.opacity === "1").length, 7); assert.equal([...layer.children].some((element) => element.className.includes("quake-impact-particle-dust-")), true); + const wallStartOffsets = [...layer.children] + .filter((element) => element.style.opacity === "1") + .map((element) => particleOffset(element.style.transform)); + const wallStartX = wallStartOffsets.map((offset) => offset.x); + const wallStartY = wallStartOffsets.map((offset) => offset.y); + assert.equal(Math.max(...wallStartX) - Math.min(...wallStartX) <= 35, true); + assert.equal(Math.max(...wallStartY) - Math.min(...wallStartY) >= 28, true); + assert.equal(minParticleDistance(wallStartOffsets) >= 10, true); + const wallStartOffset = wallStartOffsets[0]; + now += 60; + [...frames.values()][0](now); + const wallFallOffset = particleOffset(layer.children[0].style.transform); + assert.equal(wallFallOffset.y > wallStartOffset.y, true); + now += 180; + [...frames.values()][0](now); + assert.equal(layer.children[0].style.opacity !== "0", true); + flow.clear(); + + createElementCalls = 0; + flow.spawnExplosion({ count: 20, flavor: "rocket", radiusUnits: 120 }); + assert.equal(createElementCalls, 0); + assert.equal([...layer.children].filter((element) => element.style.opacity === "1").length, 8); + assert.equal([...layer.children].some((element) => element.className.includes("quake-impact-particle-explosion-")), true); + assert.equal([...layer.children].some((element) => element.className.includes("quake-impact-particle-dust-")), false); + const explosionParticles = [...layer.children].filter((element) => element.style.opacity === "1"); + const explosionDiskParticles = explosionParticles.filter((element) => + element.className.includes("quake-impact-particle-explosion-")); + assert.equal(explosionDiskParticles.length, 8); + const explosionStartOffsets = explosionDiskParticles.map((element) => particleOffset(element.style.transform)); + const explosionStartScales = explosionDiskParticles.map((element) => particleScale(element.style.transform)); + assert.equal(Math.max(...explosionStartOffsets.map((offset) => offset.x)) - + Math.min(...explosionStartOffsets.map((offset) => offset.x)) <= 2, true); + assert.equal(Math.max(...explosionStartOffsets.map((offset) => offset.y)) - + Math.min(...explosionStartOffsets.map((offset) => offset.y)) <= 2, true); + assert.equal(Math.max(...explosionStartScales) - Math.min(...explosionStartScales) > 0.5, true); + const explosionFlash = explosionParticles.find((element) => + element.className.includes("quake-impact-particle-explosion-a")); + const explosionOuter = explosionParticles.find((element) => + element.className.includes("quake-impact-particle-explosion-b")); + assert.ok(explosionFlash); + assert.ok(explosionOuter); + assert.equal(particleScale(explosionOuter.style.transform) > particleScale(explosionFlash.style.transform) * 2, true); + const explosionStartScale = particleScale(explosionFlash.style.transform); + now += 40; + [...frames.values()][0](now); + const explosionFlashScale = particleScale(explosionFlash.style.transform); + assert.equal(explosionFlashScale > explosionStartScale * 1.2, true); + flow.clear(); + + Math.random = () => 0; + flow.spawnExplosion({ count: 8, flavor: "grenade" }); + const grenadeExplosionScale = maxParticleScale(layer); + flow.clear(); + flow.spawnExplosion({ count: 8, flavor: "rocket" }); + const rocketExplosionScale = maxParticleScale(layer); + flow.clear(); + flow.spawnExplosion({ count: 8, flavor: "explobox" }); + const exploboxExplosionScale = maxParticleScale(layer); + assert.equal(grenadeExplosionScale < rocketExplosionScale, true); + assert.equal(rocketExplosionScale < exploboxExplosionScale, true); flow.clear(); flow.dispose(); @@ -151,7 +211,7 @@ test("impact particles use a fixed b-quad pool and do not allocate during spawn" canShow: () => true, isGameplayPaused: () => false, layer: distanceLayer, - maxParticles: 1, + maxParticles: 8, now: () => now, viewOrigin: () => [0, 0, 0], }); @@ -162,10 +222,29 @@ test("impact particles use a fixed b-quad pool and do not allocate during spawn" distanceFlow.clear(); distanceFlow.spawnBlood({ count: 1, origin: [100, 0, 0] }); const farScale = particleScale(distanceLayer.children[0].style.transform); + distanceFlow.clear(); + distanceFlow.spawnWallImpact({ count: 1, origin: [0, 0, 0] }); + const nearWallScale = particleScale(distanceLayer.children[0].style.transform); + distanceFlow.clear(); + distanceFlow.spawnWallImpact({ count: 1, origin: [100, 0, 0] }); + const farWallScale = particleScale(distanceLayer.children[0].style.transform); + distanceFlow.clear(); + distanceFlow.spawnExplosion({ count: 8, origin: [0, 0, 0] }); + const nearExplosionScale = maxParticleScale(distanceLayer); + distanceFlow.clear(); + distanceFlow.spawnExplosion({ count: 8, origin: [100, 0, 0] }); + const farExplosionScale = maxParticleScale(distanceLayer); assert.equal(nearScale, 2); assert.equal(farScale, 0.58); assert.equal(nearScale > farScale, true); + assert.equal(nearWallScale, 1.78); + assert.equal(farWallScale, 0.44); + assert.equal(farWallScale < farScale, true); + assert.equal(nearExplosionScale >= 18, true); + assert.equal(farExplosionScale >= 6, true); + assert.equal(nearExplosionScale > nearScale * 9, true); + assert.equal(farExplosionScale > farScale * 10, true); distanceFlow.dispose(); } finally { Math.random = previousRandom; @@ -228,3 +307,24 @@ function particleOffset(transform) { function activeParticleCount(layer) { return [...layer.children].filter((element) => element.style.opacity === "1").length; } + +function maxParticleScale(layer) { + return Math.max( + ...[...layer.children] + .filter((element) => element.style.opacity === "1") + .map((element) => particleScale(element.style.transform)), + ); +} + +function minParticleDistance(offsets) { + let minDistance = Infinity; + for (let left = 0; left < offsets.length; left++) { + for (let right = left + 1; right < offsets.length; right++) { + minDistance = Math.min( + minDistance, + Math.hypot(offsets[left].x - offsets[right].x, offsets[left].y - offsets[right].y), + ); + } + } + return minDistance; +} diff --git a/test/mobileControls.test.mjs b/test/mobileControls.test.mjs index b30ee5c..38c142f 100644 --- a/test/mobileControls.test.mjs +++ b/test/mobileControls.test.mjs @@ -6,13 +6,19 @@ import { Window } from "happy-dom"; import { importTsModule } from "./importTsModule.mjs"; const { + QUAKE_MOBILE_CONTROLS_QUERY, createQuakeMobileControls, } = await importTsModule("src/runtime/mobileControls.ts"); +test("mobile controls availability includes portrait mobile viewports", () => { + assert.equal(QUAKE_MOBILE_CONTROLS_QUERY, "(any-pointer: coarse), (max-width: 960px)"); +}); + test("mobile move stick handles pointer input and updates the visible nub", () => { const harness = createMobileControlsHarness(); try { assert.equal(harness.controls.isTarget(harness.front), true); + assertMoveVisualGeometry(harness); harness.moveZone.dispatchEvent(pointer(harness.window, "pointerdown", harness.centerX, harness.centerY, 11, 1)); assert.deepEqual(harness.analogSamples.at(-1), [0, 0]); @@ -134,9 +140,11 @@ function createMobileControlsHarness({ canUseInput = () => true } = {}) { controls.attach(); const moveZone = document.querySelector("#quake-mobile-move-zone"); + const back = document.querySelector("#quake-mobile-move-zone .back"); const front = document.querySelector("#quake-mobile-move-zone .front"); const stick = document.querySelector("#quake-mobile-move-zone .joystick"); assert.ok(moveZone instanceof window.HTMLElement); + assert.ok(back instanceof window.HTMLElement); assert.ok(front instanceof window.HTMLElement); assert.ok(stick instanceof window.HTMLElement); @@ -154,6 +162,7 @@ function createMobileControlsHarness({ canUseInput = () => true } = {}) { return { analogSamples, + back, centerX: 90, centerY: 172, controls, @@ -195,6 +204,30 @@ function assertVisualReleased(harness) { assert.equal(harness.stick.style.opacity, "0.58"); } +function assertMoveVisualGeometry(harness) { + assert.equal(harness.stick.style.left, "50%"); + assert.equal(harness.stick.style.top, "50%"); + assert.equal(harness.stick.style.width, "108px"); + assert.equal(harness.stick.style.height, "108px"); + assert.equal(harness.stick.style.marginLeft, "-54px"); + assert.equal(harness.stick.style.marginTop, "-54px"); + assert.equal(harness.stick.style.pointerEvents, "none"); + assert.equal(harness.back.style.left, "0px"); + assert.equal(harness.back.style.top, "0px"); + assert.equal(harness.back.style.width, "108px"); + assert.equal(harness.back.style.height, "108px"); + assert.equal(harness.back.style.marginLeft, "0px"); + assert.equal(harness.back.style.marginTop, "0px"); + assert.equal(harness.back.style.pointerEvents, "none"); + assert.equal(harness.front.style.left, "50%"); + assert.equal(harness.front.style.top, "50%"); + assert.equal(harness.front.style.width, "54px"); + assert.equal(harness.front.style.height, "54px"); + assert.equal(harness.front.style.marginLeft, "-27px"); + assert.equal(harness.front.style.marginTop, "-27px"); + assert.equal(harness.front.style.pointerEvents, "none"); +} + function pointer(window, type, clientX, clientY, pointerId, buttons) { return new window.PointerEvent(type, { bubbles: true, diff --git a/test/shootableExplosionParticles.test.mjs b/test/shootableExplosionParticles.test.mjs new file mode 100644 index 0000000..06dc1d6 --- /dev/null +++ b/test/shootableExplosionParticles.test.mjs @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { importTsModule } from "./importTsModule.mjs"; + +const { + createQuakeShootablesController, +} = await importTsModule("src/runtime/shootables.ts"); +const { + QUAKE_COLLISION_UNIT_SCALE, +} = await importTsModule("src/runtime/constants.ts"); + +function createEntity(index, classname) { + return { + angle: 0, + classname, + index, + origin: { x: 0, y: 0, z: 0 }, + properties: { + classname, + origin: "0 0 0", + }, + }; +} + +function createShootablesHarness() { + const explosions = []; + const destroyed = []; + const shootables = createQuakeShootablesController({ + addMesh: () => null, + damagePlayer: () => false, + fireTarget: () => undefined, + floorAt: (_x, _y, maxZ = 0) => maxZ, + getPlayerEyeHeight: () => 1, + getPlayerForward: () => [1, 0, 0], + getPlayerOrigin: () => [100, 100, 100], + hasLineOfSight: () => true, + isInPlayerView: () => true, + leafIndexAt: () => 0, + monsterRuntimeEnabled: () => false, + onDestroyed: (entity) => { destroyed.push(entity); }, + onExplosion: (event) => { explosions.push(event); }, + pixelate: () => undefined, + pointToPoly: (point) => [point.x, point.y, point.z], + schedulePresentationResync: () => undefined, + shouldSpawn: () => true, + visibleLeavesAt: () => new Set([0]), + }); + return { destroyed, explosions, shootables }; +} + +function createFakeMeshHandle(entity, model, handles) { + const handle = { + element: { + classList: { + add() {}, + remove() {}, + }, + dataset: {}, + querySelectorAll: () => [], + removeAttribute() {}, + setAttribute() {}, + style: {}, + }, + entity, + model, + removed: false, + transforms: [], + remove() { + this.removed = true; + }, + setTransform(transform) { + this.transforms.push(transform); + }, + }; + handles.push(handle); + return handle; +} + +function createModel(source, bounds) { + return { + animationFrames: [], + bounds, + source, + }; +} + +test("destroying explobox emits one explosion presentation event", () => { + const { destroyed, explosions, shootables } = createShootablesHarness(); + shootables.spawn([createEntity(1, "misc_explobox")], { + models: { + "maps/b_explob.bsp": { + animationFrames: [], + bounds: { min: [-0.42, -0.42, -0.25], max: [0.42, 0.42, 0.72] }, + }, + }, + }); + + assert.equal(shootables.damage(1, 20), true); + + assert.deepEqual(destroyed.map((entity) => entity.index), [1]); + assert.equal(explosions.length, 1); + assert.equal(explosions[0].classname, "misc_explobox"); + assert.equal(explosions[0].entityIndex, 1); + assert.equal(explosions[0].flavor, "explobox"); + assert.deepEqual(explosions[0].origin, [0, 0, -0.25]); + assert.equal(explosions[0].radiusUnits, 200); +}); + +test("destroying non-exploding shootable does not emit explosion presentation event", () => { + const { explosions, shootables } = createShootablesHarness(); + shootables.spawn([createEntity(2, "monster_dog")], { + models: { + "progs/dog.mdl": { + animationFrames: [], + bounds: { min: [-0.5, -0.5, 0], max: [0.5, 0.5, 1] }, + }, + }, + }); + + assert.equal(shootables.damage(2, 25), true); + + assert.deepEqual(explosions, []); +}); + +test("gibbed monster death output uses the source foot plane without floor queries", () => { + const previousWindow = globalThis.window; + let timeoutCalls = 0; + let animationFrameId = 0; + const animationFrames = new Map(); + globalThis.window = { + clearTimeout: globalThis.clearTimeout.bind(globalThis), + cancelAnimationFrame: (id) => { + animationFrames.delete(id); + }, + requestAnimationFrame: (callback) => { + const id = ++animationFrameId; + animationFrames.set(id, callback); + return id; + }, + setTimeout: () => { + timeoutCalls += 1; + return timeoutCalls; + }, + }; + + function runNextAnimationFrame(now) { + const next = animationFrames.entries().next().value; + assert.ok(next, "expected a pending animation frame"); + const [id, callback] = next; + animationFrames.delete(id); + callback(now); + } + + const handles = []; + let floorCalls = 0; + const shootables = createQuakeShootablesController({ + addMesh: (entity, model) => createFakeMeshHandle(entity, model, handles), + damagePlayer: () => false, + fireTarget: () => undefined, + floorAt: (_x, _y, maxZ = 0, minZ = -Infinity) => { + floorCalls += 1; + return minZ <= 0 && 0 <= maxZ ? 0 : null; + }, + getPlayerEyeHeight: () => 1, + getPlayerForward: () => [1, 0, 0], + getPlayerOrigin: () => [0, 0, 0], + hasLineOfSight: () => true, + isInPlayerView: () => true, + leafIndexAt: () => 0, + monsterRuntimeEnabled: () => false, + pixelate: () => undefined, + pointToPoly: (point) => [point.x, point.y, point.z], + schedulePresentationResync: () => undefined, + shouldSpawn: () => true, + visibleLeavesAt: () => new Set([0]), + }); + + try { + shootables.spawn([createEntity(3, "monster_dog")], { + models: { + "progs/dog.mdl": createModel("progs/dog.mdl", { + min: [-0.5, -0.5, 0], + max: [0.5, 0.5, 1], + }), + "progs/gib3.mdl": createModel("progs/gib3.mdl", { + min: [-0.12, -0.12, -0.12], + max: [0.12, 0.12, 0.12], + }), + "progs/h_dog.mdl": createModel("progs/h_dog.mdl", { + min: [-0.2, -0.2, -0.18], + max: [0.2, 0.2, 0.18], + }), + }, + }); + + assert.equal(shootables.debugMountEntity(3), true); + const sourceFootZ = 4; + const dogCollisionMinZ = -24 * QUAKE_COLLISION_UNIT_SCALE; + assert.equal(shootables.debugSetOrigin(3, [0, 0, sourceFootZ - dogCollisionMinZ]), true); + const floorCallsBeforeDamage = floorCalls; + assert.equal(shootables.damage(3, 100), true); + assert.equal(floorCalls, floorCallsBeforeDamage); + assert.equal(timeoutCalls, 0); + assert.equal(animationFrames.size, 1); + + const deathOutputs = handles.filter((handle) => handle.entity.classname === "monster_death_output"); + assert.equal(deathOutputs.length, 4); + for (const output of deathOutputs) { + const finalTransform = output.transforms.at(-1); + assert.ok(finalTransform, "death output should receive a transform"); + assert.ok(Math.abs(finalTransform.position[2] + output.model.bounds.min[2] - sourceFootZ) < 1e-9); + } + + runNextAnimationFrame(16); + assert.ok( + deathOutputs.some((output) => { + const transform = output.transforms.at(-1); + return transform && transform.position[2] + output.model.bounds.min[2] > sourceFootZ; + }), + "at least one death output should arc above the source foot plane", + ); + + for (let now = 66; animationFrames.size > 0 && now <= 2000; now += 50) { + runNextAnimationFrame(now); + } + assert.equal(animationFrames.size, 0); + for (const output of deathOutputs) { + const finalTransform = output.transforms.at(-1); + assert.ok(finalTransform, "death output should receive a final transform"); + assert.ok(Math.abs(finalTransform.position[2] + output.model.bounds.min[2] - sourceFootZ) < 1e-9); + } + } finally { + shootables.clear(); + if (previousWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = previousWindow; + } + } +}); diff --git a/test/weaponImpactParticles.test.mjs b/test/weaponImpactParticles.test.mjs index 243b886..24d9704 100644 --- a/test/weaponImpactParticles.test.mjs +++ b/test/weaponImpactParticles.test.mjs @@ -6,6 +6,9 @@ import { importTsModule } from "./importTsModule.mjs"; const { createQuakeWeaponsController, } = await importTsModule("src/runtime/weapons.ts"); +const { + createQuakeShootablesController, +} = await importTsModule("src/runtime/shootables.ts"); function createShootable(index, origin = [0, 0, 0]) { return { @@ -23,12 +26,19 @@ function createShootable(index, origin = [0, 0, 0]) { }; } -function createWeaponsHarness({ activeWeapon = "rocketlauncher", collisionWorld = null, shootables }) { +function createWeaponsHarness({ + activeWeapon = "rocketlauncher", + collisionWorld = null, + damageShootable = null, + entities = null, + shootables, +}) { const damageCalls = []; + const explosionImpacts = []; const impacts = []; const wallImpacts = []; let hits = 0; - const entities = new Map(shootables.map((shootable) => [shootable.entity.index, shootable.entity])); + const entitiesByIndex = entities ?? new Map(shootables.map((shootable) => [shootable.entity.index, shootable.entity])); const weapons = createQuakeWeaponsController({ addProjectileMesh: () => null, canUseGameplayInput: () => true, @@ -39,19 +49,20 @@ function createWeaponsHarness({ activeWeapon = "rocketlauncher", collisionWorld damageBrushEntity: () => true, damageMultiplier: () => 1, damagePlayer: () => false, - damageShootable: (entityIndex, amount) => { + damageShootable: (entityIndex, amount, context) => { damageCalls.push({ amount, entityIndex }); - return true; + return damageShootable?.(entityIndex, amount, context) ?? true; }, getActiveWeapon: () => activeWeapon, getAmmo: () => 999, getCollisionWorld: () => collisionWorld, - getEntities: () => entities, + getEntities: () => entitiesByIndex, getPlayerEyeHeight: () => 1.7, getPlayerWaterLevel: () => 0, getShootables: () => shootables, hasViewmodel: () => true, onDamageImpact: (event) => { impacts.push(event); }, + onExplosionImpact: (event) => { explosionImpacts.push(event); }, onHit: () => { hits += 1; }, onWallImpact: (event) => { wallImpacts.push(event); }, playFireAnimation: () => undefined, @@ -69,7 +80,52 @@ function createWeaponsHarness({ activeWeapon = "rocketlauncher", collisionWorld syncCrosshairTarget: () => undefined, syncHud: () => undefined, }); - return { damageCalls, impacts, hits: () => hits, wallImpacts, weapons }; + return { damageCalls, explosionImpacts, impacts, hits: () => hits, wallImpacts, weapons }; +} + +function createExploboxEntity(index) { + return { + angle: 0, + classname: "misc_explobox", + index, + origin: { x: 0, y: 0, z: 0 }, + properties: { + classname: "misc_explobox", + origin: "0 0 0", + }, + }; +} + +function createExploboxShootablesHarness(entity) { + const explosions = []; + const shootables = createQuakeShootablesController({ + addMesh: () => null, + damagePlayer: () => false, + fireTarget: () => undefined, + floorAt: (_x, _y, maxZ = 0) => maxZ, + getPlayerEyeHeight: () => 1, + getPlayerForward: () => [1, 0, 0], + getPlayerOrigin: () => [100, 100, 100], + hasLineOfSight: () => true, + isInPlayerView: () => true, + leafIndexAt: () => 0, + monsterRuntimeEnabled: () => false, + onExplosion: (event) => { explosions.push(event); }, + pixelate: () => undefined, + pointToPoly: (point) => [point.x, point.y, point.z], + schedulePresentationResync: () => undefined, + shouldSpawn: () => true, + visibleLeavesAt: () => new Set([0]), + }); + shootables.spawn([entity], { + models: { + "maps/b_explob.bsp": { + animationFrames: [], + bounds: { min: [-0.42, -0.42, 0], max: [0.42, 0.42, 0.72] }, + }, + }, + }); + return { explosions, shootables }; } test("projectile direct shootable damage emits one damage-impact event", () => { @@ -92,8 +148,38 @@ test("projectile direct shootable damage emits one damage-impact event", () => { assert.equal(impacts[0].weapon, "nailgun"); }); +test("projectile direct hit on explobox emits explobox explosion through shootables", () => { + const explobox = createExploboxEntity(7); + const shootablesHarness = createExploboxShootablesHarness(explobox); + const { damageCalls, explosionImpacts, impacts, hits, weapons } = createWeaponsHarness({ + activeWeapon: "nailgun", + damageShootable: (entityIndex, amount, context) => + shootablesHarness.shootables.damage(entityIndex, amount, context), + entities: new Map([[explobox.index, explobox]]), + shootables: [{ + bounds: { min: [-0.42, -0.42, 0], max: [0.42, 0.42, 0.72] }, + dead: false, + entity: explobox, + origin: [0, 0, 0], + }], + }); + + const result = weapons.debugProjectileImpact("nailgun", explobox.index, [0, 0, 0], 20); + + assert.equal(result?.impactResult, "remove"); + assert.equal(hits(), 1); + assert.deepEqual(damageCalls.map((call) => call.entityIndex), [explobox.index]); + assert.equal(impacts.length, 1); + assert.equal(explosionImpacts.length, 0); + assert.equal(shootablesHarness.explosions.length, 1); + assert.equal(shootablesHarness.explosions[0].classname, "misc_explobox"); + assert.equal(shootablesHarness.explosions[0].entityIndex, explobox.index); + assert.equal(shootablesHarness.explosions[0].flavor, "explobox"); + assert.equal(shootablesHarness.explosions[0].radiusUnits, 200); +}); + test("projectile world impact emits one spike wall-impact event", () => { - const { damageCalls, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ + const { damageCalls, explosionImpacts, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ shootables: [], }); @@ -103,6 +189,7 @@ test("projectile world impact emits one spike wall-impact event", () => { assert.equal(result?.directEntityIndex, null); assert.equal(hits(), 0); assert.equal(damageCalls.length, 0); + assert.equal(explosionImpacts.length, 0); assert.equal(impacts.length, 0); assert.equal(wallImpacts.length, 1); assert.deepEqual(wallImpacts[0].direction, [0, -1, 0]); @@ -138,8 +225,8 @@ test("hitscan wall traces emit one aggregated gunshot wall-impact event", () => assert.equal(wallImpacts[0].weapon, "shotgun"); }); -test("projectile splash-only damage does not emit damage-impact events", () => { - const { damageCalls, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ +test("projectile splash-only damage emits explosion but not damage-impact events", () => { + const { damageCalls, explosionImpacts, impacts, hits, wallImpacts, weapons } = createWeaponsHarness({ shootables: [ createShootable(1, [0, 0, 0]), createShootable(2, [1, 0, 0]), @@ -152,5 +239,10 @@ test("projectile splash-only damage does not emit damage-impact events", () => { assert.equal(hits(), 1); assert.equal(damageCalls.length > 0, true); assert.equal(impacts.length, 0); + assert.equal(explosionImpacts.length, 1); + assert.equal(explosionImpacts[0].flavor, "rocket"); + assert.deepEqual(explosionImpacts[0].origin, [0, 0.16, 0]); + assert.equal(explosionImpacts[0].radiusUnits, 160); + assert.equal(explosionImpacts[0].weapon, "rocketlauncher"); assert.equal(wallImpacts.length, 0); });