diff --git a/config/AcediaFixes.ini b/config/AcediaFixes.ini index 7acc9b9..1a3415b 100644 --- a/config/AcediaFixes.ini +++ b/config/AcediaFixes.ini @@ -218,6 +218,42 @@ alwaysScale=Class'KFMod.DamTypeZEDGunMKII' ; Damage types, for which we should never reaply friendly fire scaling. ;neverScale=Class'KFMod.???' +[AcediaFixes.FixPipes] +; This feature addresses several bugs related to pipe bombs: +; 1. Gaining extra explosive damage by shooting pipes with +; a high fire rate weapon; +; 2. Other players exploding one's pipes; +; 3. Corpses and story NPCs exploding nearby pipes; +; 4. Pipes being stuck in places where they cannot detect nearby +; enemies and, therefore, not exploding. +autoEnable=true +; NOTE #1: setting either of `preventMassiveDamage` or +; `preventSuspiciousDamage` might change how and where pipes might fall on +; the ground (for example, pipe hats shoul not work anymore). In most cases, +; however, there should be no difference from vanilla gameplay. +; Setting this to `true` fixes a mechanic that allows pipes to deal extra +; damage when detonated with a high fire rate weapons: pipes will now always +; deal the same amount of damage. +preventMassiveDamage=true +; It's possible for teammates to explode one's pipe under certain +; circumstances. Setting this setting to `true` will prevent that +; from happening. +preventSuspiciousDamage=true +; Setting this to `true` will prevent pipe bombs from being detonated by +; the nearby corpses on other player. +preventCorpseDetonation=true +; Setting this to `true` will prevents pipe bombs from being detonated by +; nearby KFO NPCs (Ringmaster Lockheart). +preventNPCDetonation=true +; Certain spots prevent pipe bombs from having a line of sight to the +; nearby zeds, preventing them from ever exploding. This issue can be resolves +; by checking zed's proximity not to the pipe itself, but to a point directly +; above it (20 units above by default). +; If you wish to disable this part of the feature - set it to zero or +; negative value. Otherwise it is suggested to keep it at `20`. +proximityCheckElevation=20 + + [AcediaFixes.FixProjectileFF] ; This feature addresses the bug that allows teammates to explode some of ; the player's projectiles by damaging them even when friendly fire is diff --git a/sources/FixPipes/FixPipes.uc b/sources/FixPipes/FixPipes.uc new file mode 100644 index 0000000..a33916c --- /dev/null +++ b/sources/FixPipes/FixPipes.uc @@ -0,0 +1,422 @@ +/** + * This feature addresses several bugs related to pipe bombs: + * 1. Gaining extra explosive damage by shooting pipes with + * a high fire rate weapon; + * 2. Other players exploding one's pipes; + * 3. Corpses and story NPCs exploding nearby pipes; + * 4. Pipes being stuck in places where they cannot detect nearby + * enemies and, therefore, not exploding. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class FixPipes extends Feature + config(AcediaFixes); + +/** + * There are two main culprits for the issues we are interested in: + * `TakeDamage()` and `Timer()` methods. `TakeDamage()` lacks sufficient checks + * for instigator allows for ways to damage it, causing explosion. It also + * lacks any checks for whether pipe bom already exploded, allowing players to + * damage it several time before it's destroyed, causing explosion every time. + * `Timer()` method simply contains a porximity check for hostile pawns that + * wrongfully detects certain actor (like `KF_StoryNPC` or players' corpses) + * and cannot detect zeds around itself if pipe is placed in a way that + * prevents it from having a direct line of sight towards zeds. + * To fix our issues we have to somehow overload both of these methods, + * which is impossible while keeping Acedia server-side. So we will have to + * do some hacks. + * + * `TakeDamage()`. To override this method's behavior we will remove actor + * collision from pipe bombs, preventing it from being directly damaged, then + * add an `ExtendedZCollision` subclass around it to catch `TakeDamage()` + * events that would normally target pipe bombs themselves. There we can add + * additional checks to help us decide whether pipe bombs should actually + * be damaged. + * + * `Timer()`. It would be simple to take control of this method by, say, + * spamming `SetTimer(0.0, false)` every tick and then executing our own logic + * elsewhere. However we only care about changin `Timer()`'s logic when pipe + * bomb attempts to detect cnearby enemies and do not want to manage the rest + * of `Timer()`'s logic. + * Pipe bombs work like this: after being thrown they fall until they land, + * invoking `Landed()` and `HitWall()` calls that start up the timer. Then + * timer uses `ArmingCountDown` variable to count down the time for pipe to + * "arm", that is, start functioning. It's after that that pipe starts to try + * and detect nearby enemies until it decides to start exploding sequence, + * instantly exploded with damage or disintegrated by siren's scream. + * We want to catch the moment when `ArmingCountDown` reaches zero and + * only then disable the timer. Then case we'll only need to reimplement logic + * that takes care of enemy detection. We also do not need to constantly + * re-reset the timer, since any code that will restart it would mean that + * pipe bomb no longer needs to look for enemies. + * When we detect enough enemies nearby or a change in pipe bomb's state + * due to outside factors (being exploded or disintegrated) we simply need to + * start it's the timer (if it was not done for us already). + */ + +// NOTE #1: setting either of `preventMassiveDamage` or +// `preventSuspiciousDamage` might change how and where pipes might fall on +// the ground (for example, pipe hats shoul not work anymore). In most cases, +// however, there should be no difference from vanilla gameplay. +// Setting this to `true` fixes a mechanic that allows pipes to deal extra +// damage when detonated with a high fire rate weapons: pipes will now always +// deal the same amount of damage. +var public const config bool preventMassiveDamage; +// It's possible for teammates to explode one's pipe under certain +// circumstances. Setting this setting to `true` will prevent that +// from happening. +var public const config bool preventSuspiciousDamage; +// Setting this to `true` will prevent pipe bombs from being detonated by +// the nearby corpses on other player. +var public const config bool preventCorpseDetonation; +// Setting this to `true` will prevents pipe bombs from being detonated by +// nearby KFO NPCs (Ringmaster Lockheart). +var public const config bool preventNPCDetonation; +// Certain spots prevent pipe bombs from having a line of sight to the +// nearby zeds, preventing them from ever exploding. This issue can be resolves +// by checking zed's proximity not to the pipe itself, but to a point directly +// above it (20 units above by default). +// If you wish to disable this part of the feature - set it to zero or +// negative value. Otherwise it is suggested to keep it at `20`. +var public const config float proximityCheckElevation; + +// Since this feature needs to do proximity check instead of pipes, we will +// need to track all the pipes we will be doing checks for. +// This struct contains all reference to the pipe itself and everything +// else we need to track. +struct PipeRecord +{ + // Pipe that this record tracks + var PipeBombProjectile pipe; + // Each pipe has a separate timer for their scheduled proximity checks, + // so we need a separate variable to track when that time comes for + // each and every pipe + var float timerCountDown; + // `true` if we have already intercepted (and replaced with our own) + // the proximity check for the pipe in this record + var bool proximityCheckIntercepted; + // Reference to the `ExtendedZCollision` we created to catch + // `TakeDamage()` event. + var PipesSafetyCollision safetyCollision; +}; +var private array pipeRecords; + +// Store the `bAlwaysRelevant` of the `class'PipeBombProjectile'` before this +// feature activated to restore it in case it gets disabled +// (we need to change `bAlwaysRelevant` in order to catch events of +// pipe bombs spawning). +var private bool pipesRelevancyFlag; + +protected function OnEnabled() +{ + local PipeBombProjectile nextPipe; + pipesRelevancyFlag = class'PipeBombProjectile'.default.bAlwaysRelevant; + class'PipeBombProjectile'.default.bGameRelevant = false; + // Fix pipes that are already lying about on the map + foreach level.DynamicActors(class'KFMod.PipeBombProjectile', nextPipe) { + RegisterPipe(nextPipe); + } +} + +protected function OnDisabled() +{ + local int i; + class'PipeBombProjectile'.default.bGameRelevant = pipesRelevancyFlag; + for (i = 0; i < pipeRecords.length; i += 1) { + ReleasePipe(pipeRecords[i]); + } + pipeRecords.length = 0; +} + +// Adds new pipe to our list and does necessary steps to replace logic of +// `TakeDamage()` and `Timer()` methods. +public final function RegisterPipe(PipeBombProjectile newPipe) +{ + local int i; + local PipeRecord newRecord; + if (newPipe == none) { + return; + } + // Check whether we have already added this pipe + for (i = 0; i < pipeRecords.length; i += 1) + { + if (pipeRecords[i].pipe == newPipe) { + return; + } + } + newRecord.pipe = newPipe; + // Setup `PipesSafetyCollision` for catching `TakeDamage()` events + // (only if we need to according to settings) + if (NeedSafetyCollision()) + { + newRecord.safetyCollision = + class'PipesSafetyCollision'.static.ProtectPipes(newPipe); + } + newRecord.pipe = newPipe; + pipeRecords[pipeRecords.length] = newRecord; + // Intercept proximity checks (only if we need to according to settings) + if (NeedManagedProximityChecks()) + { + // We do this after we have added new pipe record to the complete list + // so that we can redo the check early for + // the previously recorded pipes + InterceptProximityChecks(); + } +} + +// Rolls back our changes to the pipe in the given `PipeRecord`. +public final function ReleasePipe(PipeRecord pipeRecord) +{ + if (pipeRecord.safetyCollision != none) + { + pipeRecord.safetyCollision.TurnOff(); + pipeRecord.safetyCollision = none; + } + if (pipeRecord.proximityCheckIntercepted && pipeRecord.pipe != none) + { + pipeRecord.proximityCheckIntercepted = false; + if (IsPipeDoingProximityChecks(pipeRecord.pipe)) { + pipeRecord.pipe.SetTimer(pipeRecord.timerCountDown, true); + } + } +} + +// Checks whether we actually need to use replace logic of `TakeDamage()` +// method according to settings. +private final function bool NeedSafetyCollision() +{ + if (preventMassiveDamage) return true; + if (preventSuspiciousDamage) return true; + return false; +} + +// Checks whether we actually need to use replace logic of `Timer()` +// method according to settings. +private final function bool NeedManagedProximityChecks() +{ + if (preventCorpseDetonation) return true; + if (preventNPCDetonation) return true; + if (proximityCheckElevation > 0.0) return true; + return false; +} + +// Removes dead records with pipe instances turned into `none` +private final function CleanPipeRecords() +{ + local int i; + while (i < pipeRecords.length) + { + if (pipeRecords[i].pipe == none) { + pipeRecords.Remove(i, 1); + } + else { + i += 1; + } + } +} + +// Tries to replace logic of `SetTimer()` with our own for every pipe we +// keep track of that: +// 1. Started doing proximity checks; +// 2. Have not yet had it's logic replaced. +private final function InterceptProximityChecks() +{ + local int i; + for (i = 0; i < pipeRecords.length; i += 1) + { + if (pipeRecords[i].proximityCheckIntercepted) continue; + if (pipeRecords[i].pipe == none) continue; + if (IsPipeDoingProximityChecks(pipeRecords[i].pipe)) + { + pipeRecords[i].pipe.SetTimer(0, false); + // Line 123 of "PipeBombProjectile.uc": `SetTimer(1.0,True);` + pipeRecords[i].timerCountDown = 1.0; + pipeRecords[i].proximityCheckIntercepted = true; + } + } +} + +// Assumes `pipe != none` +private final function bool IsPipeDoingProximityChecks(PipeBombProjectile pipe) +{ + // These checks need to pass so that `Timer()` call from + // "PipeBombProjectile.uc" enters the proximity check code + // (starting at line 131) + if (pipe.bHidden) return false; + if (pipe.bTriggered) return false; + if (pipe.bEnemyDetected) return false; + return (pipe.ArmingCountDown < 0); +} + +// Checks what pipes have their timers run out and doing proximity checks +// for them +private final function PerformProximityChecks(float delta) +{ + local int i; + local Vector checkLocation; + for (i = 0; i < pipeRecords.length; i += 1) + { + if (pipeRecords[i].pipe == none) continue; + // `timerCountDown` does not makes sense for pipes that + // are not doing proxiity checks + if (!IsPipeDoingProximityChecks(pipeRecords[i].pipe)) continue; + + pipeRecords[i].timerCountDown -= delta; + if (pipeRecords[i].timerCountDown <= 0) + { + checkLocation = pipeRecords[i].pipe.location; + if (proximityCheckElevation > 0) { + checkLocation.z += proximityCheckElevation; + } + // This method repeats vanilla logic with some additional checks + // and sets new timers by itself + DoPipeProximityCheck(pipeRecords[i], checkLocation); + } + } +} + +// Assumes `pipeRecord.pipe != none` +// Original code is somewhat messy and was reworked in this more manageble +// form as core logic is simple - for every nearby `Pawn` we increase the +// percieved level for the pipe and when it reaches certain threshold +// (`threatThreshhold = 1.0`) pipe explodes. +// In native logic there are three cases for increasing threat level: +// 1. Zeds increase it by a certain predefined +// (in `KFMonster` class amount); +// 2. Instigator (and his team, in case friendly fire is enabled and +// they can be hurt by pipes) raise threat level by a negligible +// amount (`0.001`) that will not lead to pipes exploding, but +// will cause them to beep faster, warning about the danger; +// 3. Some wacky code to check whether the `Pawn` is on the same team. +// In a regualar killing floor game only something that is neither +// player or zed can fit that. This is what causes corpses and +// KFO NPCs to explode pipes. +// Threat level is not directly set in vanilla code for +// this case, instead performing two operations +// `bEnemyDetected=true;` and `SetTimer(0.15,True);`. +// However, given the code that follows these calls, executing them +// is equivalent to adding `threatThreshhold` to the current +// threat level. Which is what we do here instead. +private final function DoPipeProximityCheck( + out PipeRecord pipeRecord, + Vector checkLocation) +{ + local Pawn checkPawn; + local float threatLevel; + local PipeBombProjectile pipe; + pipe = pipeRecord.pipe; + pipe.bAlwaysRelevant = false; + pipe.PlaySound(pipe.beepSound,, 0.5,, 50.0); + // Out rewritten logic, which should do exactly the same: + foreach VisibleCollidingActors( class'Pawn', checkPawn, + pipe.detectionRadius, checkLocation) + { + threatLevel += GetThreatLevel(pipe, checkPawn); + // Explosion! No need to bother with the rest of the `Pawn`s. + if (threatLevel >= pipe.threatThreshhold) { + break; + } + } + // Resume vanilla code from "PipeBombProjectile.uc", lines from 169 to 181: + if(threatLevel >= pipe.threatThreshhold) + { + pipe.bEnemyDetected = true; + pipe.SetTimer(0.15, true); + } + else if(threatLevel > 0) { + pipeRecord.timerCountDown = 0.5; + } + else { + pipeRecord.timerCountDown = 1.0; + } +} + +// Threat level calculations are moves to a separate method to make algorithm +// less cumbersome +private final function float GetThreatLevel( + PipeBombProjectile pipe, + Pawn checkPawn) +{ + local bool onSameTeam; + local bool friendlyFireEnabled; + local KFGameType kfGame; + local PlayerReplicationInfo playerRI; + local KFMonster zed; + if (pipe == none) return 0.0; + if (checkPawn == none) return 0.0; + + playerRI = checkPawn.playerReplicationInfo; + if (pipe.level != none) { + kfGame = KFGameType(pipe.level.game); + } + if (kfGame != none) { + friendlyFireEnabled = kfGame.friendlyFireScale > 0; + } + // Warn teammates about pipes + onSameTeam = playerRI != none && playerRI.team.teamIndex == pipe.placedTeam; + if (checkPawn == pipe.instigator || (friendlyFireEnabled && onSameTeam)) { + return 0.001; + } + // Count zed's threat score + zed = KFMonster(checkPawn); + if (zed != none) { + return zed.motionDetectorThreat; + } + // Managed checks + if (preventCorpseDetonation && checkPawn.health <= 0) { + return 0.0; + } + if (preventNPCDetonation && KF_StoryNPC(CheckPawn) != none) { + return 0.0; + } + // Something weird demands a special full-alert treatment. + // Some checks are removed: + // 1. Removed `checkPawn.Role == ROLE_Authority` check, since we are + // working on server exclusively; + // 2. Removed `checkPawn != instigator` since, if + // `checkPawn == pipe.instigator`, previous `if` block will + // prevent us from reaching this point + if( playerRI != none && playerRI.team.teamIndex != pipe.placedTeam + || checkPawn.GetTeamNum() != pipe.placedTeam) + { + // Full threat score + return pipe.threatThreshhold; + } + return 0.0; +} + +event Tick(float delta) +{ + CleanPipeRecords(); + if (NeedManagedProximityChecks()) + { + InterceptProximityChecks(); + PerformProximityChecks(delta); + } +} + +defaultproperties +{ + preventMassiveDamage = true + preventSuspiciousDamage = true + onlyZedsAndPlayersCanDetonate = true + preventCorpseDetonation = true + preventNPCDetonation = true + proximityCheckElevation = 20.0 + // Listeners + requiredListeners(0) = class'MutatorListener_FixPipes' +} \ No newline at end of file diff --git a/sources/FixPipes/MutatorListener_FixPipes.uc b/sources/FixPipes/MutatorListener_FixPipes.uc new file mode 100644 index 0000000..92a880a --- /dev/null +++ b/sources/FixPipes/MutatorListener_FixPipes.uc @@ -0,0 +1,39 @@ +/** + * Overloaded mutator events listener to register spawned pipe bombs. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class MutatorListener_FixPipes extends MutatorListenerBase + abstract; + +static function bool CheckReplacement(Actor other, out byte isSuperRelevant) +{ + local FixPipes pipesFix; + local PipeBombProjectile pipeProjectile; + pipeProjectile = PipeBombProjectile(other); + if (pipeProjectile == none) return true; + pipesFix = FixPipes(class'FixPipes'.static.GetInstance()); + if (pipesFix == none) return true; + + pipesFix.RegisterPipe(pipeProjectile); + return true; +} + +defaultproperties +{ + relatedEvents = class'MutatorEvents' +} \ No newline at end of file diff --git a/sources/FixPipes/PipesSafetyCollision.uc b/sources/FixPipes/PipesSafetyCollision.uc new file mode 100644 index 0000000..90c25fc --- /dev/null +++ b/sources/FixPipes/PipesSafetyCollision.uc @@ -0,0 +1,105 @@ +/** + * This collision attaches itself to pipes to catch `TakeDamage()` events + * in their place and only propagating them after additional checks. + * Copyright 2021 Anton Tarasenko + *------------------------------------------------------------------------------ + * This file is part of Acedia. + * + * Acedia is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License, or + * (at your option) any later version. + * + * Acedia is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Acedia. If not, see . + */ +class PipesSafetyCollision extends ExtendedZCollision; + +// Static function that raplaces `PipeBombProjectile` damage detectino with +// safer `PipesSafetyCollision`'s one. +public final static function PipesSafetyCollision ProtectPipes( + PipeBombProjectile target) +{ + local PipesSafetyCollision newCollision; + if (target == none) return none; + + newCollision = target.Spawn(class'PipesSafetyCollision', target); + newCollision.SetCollision(true); + newCollision.SetCollisionSize( target.collisionRadius, + target.collisionHeight); + newCollision.SetLocation(target.location); + newCollision.SetPhysics(PHYS_None); + newCollision.SetBase(target); + newCollision.SetTimer(1.0, true); + target.SetCollision(false); + return newCollision; +} + +// Same method for checking suspicious `Pawn`s as in `FixFFHack`. +// Copy-pasting it once is fine-ish, but if we will need it for another fix - +// we will have to move it out into a general method, independed from +// particular fixes. +private function bool IsSuspicious(Pawn instigator) +{ + // Instigator vanished + if (instigator == none) return true; + + // Instigator already became spectator + if (KFPawn(instigator) != none) + { + if (instigator.playerReplicationInfo != none) { + return instigator.playerReplicationInfo.bOnlySpectator; + } + return true; // Replication info is gone => suspicious + } + return false; +} + +// Revert changes made by caller `PipesSafetyCollision`, letting corresponding +// pipes catch damage events on their own again. +public final function TurnOff() +{ + if (owner != none) { + owner.SetCollision(true); + } + SetOwner(none); + Destroy(); +} + +// `TakeDamage()` with added checks +function TakeDamage( + int damage, + Pawn instigator, + Vector hitlocation, + Vector momentum, + class damageType, + optional int hitIndex) +{ + local FixPipes pipesFix; + local PipeBombProjectile target; + target = PipeBombProjectile(owner); + if (target == none) return; + pipesFix = FixPipes(class'FixPipes'.static.GetInstance()); + if (pipesFix == none) return; + if (pipesFix.preventMassiveDamage && target.bTriggered) return; + if (pipesFix.preventSuspiciousDamage && IsSuspicious(instigator)) return; + + owner.TakeDamage( damage, instigator, hitlocation, + momentum, damageType, hitIndex); +} + +event Timer() +{ + if (owner == none) { + Destroy(); + } +} + +defaultproperties +{ +} \ No newline at end of file diff --git a/sources/Manifest.uc b/sources/Manifest.uc index b17c834..538e6ab 100644 --- a/sources/Manifest.uc +++ b/sources/Manifest.uc @@ -31,4 +31,5 @@ defaultproperties features(6) = class'FixDualiesCost' features(7) = class'FixInventoryAbuse' features(8) = class'FixProjectileFF' + features(9) = class'FixPipes' } \ No newline at end of file