Add wildlife performance limits

This commit is contained in:
2026-05-18 14:21:34 -07:00
parent 494fe6f2ef
commit a834eb74c4
5 changed files with 151 additions and 2 deletions
+4 -1
View File
@@ -691,7 +691,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
max active population, spawn radius, respawn interval, and optional navigation
projection for MVP wildlife population control.
- [x] Add replication.
- [ ] Add performance limits.
- [x] Add performance limits. Wildlife now has MVP server-side performance
throttling: nearby wildlife updates normally, far wildlife batches server
thinking on a slower interval, dead wildlife stops ticking, and spawn-manager
max active population remains the first population cap.
## 0.1.L Basic Multiplayer
+8
View File
@@ -550,6 +550,14 @@ spawn radius, and respawn interval. Spawn points optionally project to navigatio
before spawning so the first wildlife prototype favors reachable positions while
still falling back cleanly on maps without complete nav data.
### Wildlife Performance Limits
MVP wildlife uses simple server-side performance guardrails before the later
AI-scaling pass. Nearby wildlife updates every tick for responsive flee/chase
behavior. Wildlife outside `FullUpdateRadius` batches server thinking on
`FarUpdateIntervalSeconds`, dead wildlife disables ticking, and spawn managers
enforce `MaxActiveWildlife` so prototype populations cannot grow without a cap.
### JSON Metadata
Use JSON files for external terrain/tile pipeline metadata while the pipeline is
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Validate MVP wildlife performance-limit wiring."""
from pathlib import Path
import sys
ROOT = Path(__file__).resolve().parents[1]
def read(relative_path: str) -> str:
path = ROOT / relative_path
if not path.exists():
raise AssertionError(f"Missing required file: {relative_path}")
return path.read_text(encoding="utf-8")
def require(content: str, needle: str, context: str) -> None:
if needle not in content:
raise AssertionError(f"Missing {needle!r} in {context}")
def main() -> int:
errors: list[str] = []
checks = {
"Source/AgrarianGame/AgrarianWildlifeBase.h": [
"bEnablePerformanceLimits",
"FullUpdateRadius",
"FarUpdateIntervalSeconds",
"ShouldRunServerThink",
"GetNearestPlayerDistanceSquared",
"ServerThinkAccumulator",
],
"Source/AgrarianGame/AgrarianWildlifeBase.cpp": [
"ShouldRunServerThink(DeltaSeconds)",
"ServerThinkAccumulator += DeltaSeconds",
"FMath::Square(FullUpdateRadius)",
"FarUpdateIntervalSeconds",
"SetActorTickEnabled(false)",
"TNumericLimits<float>::Max()",
],
"Source/AgrarianGame/AgrarianWildlifeSpawnManager.h": [
"MaxActiveWildlife",
],
"Source/AgrarianGame/AgrarianWildlifeSpawnManager.cpp": [
"GetActiveWildlifeCount() >= MaxActiveWildlife",
],
"AGRARIAN_DEVELOPMENT_ROADMAP.md": [
"[x] Add performance limits.",
"nearby wildlife updates normally",
"far wildlife batches server",
],
"Docs/TechnicalDesignDocument.md": [
"Wildlife Performance Limits",
"FullUpdateRadius",
"FarUpdateIntervalSeconds",
"MaxActiveWildlife",
],
}
for relative_path, needles in checks.items():
try:
content = read(relative_path)
for needle in needles:
require(content, needle, relative_path)
except AssertionError as exc:
errors.append(str(exc))
if errors:
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
return 1
print("PASS: wildlife performance limits are wired and documented.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+49 -1
View File
@@ -45,7 +45,12 @@ void AAgrarianWildlifeBase::Tick(float DeltaSeconds)
if (HasAuthority())
{
ServerThink(DeltaSeconds);
if (ShouldRunServerThink(DeltaSeconds))
{
const float EffectiveDeltaSeconds = bEnablePerformanceLimits ? ServerThinkAccumulator : DeltaSeconds;
ServerThink(EffectiveDeltaSeconds);
ServerThinkAccumulator = 0.0f;
}
}
}
@@ -134,6 +139,22 @@ void AAgrarianWildlifeBase::OnRep_WildlifeState()
BroadcastStateChanged();
}
bool AAgrarianWildlifeBase::ShouldRunServerThink(float DeltaSeconds)
{
if (!bEnablePerformanceLimits || WildlifeState == EAgrarianWildlifeState::Dead)
{
return true;
}
ServerThinkAccumulator += DeltaSeconds;
if (GetNearestPlayerDistanceSquared() <= FMath::Square(FullUpdateRadius))
{
return true;
}
return ServerThinkAccumulator >= FarUpdateIntervalSeconds;
}
void AAgrarianWildlifeBase::ServerThink(float DeltaSeconds)
{
if (!IsAlive())
@@ -354,6 +375,7 @@ void AAgrarianWildlifeBase::EnterDeadState()
ClearNavigationMove();
GetCharacterMovement()->StopMovementImmediately();
GetCharacterMovement()->DisableMovement();
SetActorTickEnabled(false);
SetLifeSpan(0.0f);
}
@@ -389,6 +411,32 @@ AAgrarianGameCharacter* AAgrarianWildlifeBase::FindNearestPlayer(float Radius) c
return NearestCharacter;
}
float AAgrarianWildlifeBase::GetNearestPlayerDistanceSquared() const
{
UWorld* World = GetWorld();
if (!World)
{
return TNumericLimits<float>::Max();
}
float BestDistanceSq = TNumericLimits<float>::Max();
TArray<AActor*> PlayerPawns;
UGameplayStatics::GetAllActorsOfClass(World, AAgrarianGameCharacter::StaticClass(), PlayerPawns);
for (AActor* Actor : PlayerPawns)
{
const AAgrarianGameCharacter* Candidate = Cast<AAgrarianGameCharacter>(Actor);
if (!Candidate)
{
continue;
}
BestDistanceSq = FMath::Min(BestDistanceSq, FVector::DistSquared(GetActorLocation(), Candidate->GetActorLocation()));
}
return BestDistanceSq;
}
bool AAgrarianWildlifeBase::Harvest(AAgrarianGameCharacter* Interactor)
{
if (!Interactor || WildlifeState != EAgrarianWildlifeState::Dead || bHarvested)
@@ -78,6 +78,15 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement")
FVector NavigationProjectionExtent = FVector(500.0f, 500.0f, 900.0f);
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Performance")
bool bEnablePerformanceLimits = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Performance", meta = (ClampMin = "0"))
float FullUpdateRadius = 6500.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Performance", meta = (ClampMin = "0.1"))
float FarUpdateIntervalSeconds = 2.5f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Harvest")
TArray<FAgrarianItemStack> HarvestYields;
@@ -106,6 +115,7 @@ protected:
UFUNCTION()
void OnRep_WildlifeState();
bool ShouldRunServerThink(float DeltaSeconds);
void ServerThink(float DeltaSeconds);
void ChooseWanderTarget();
void MoveTowardTarget();
@@ -116,6 +126,7 @@ protected:
void DirectMoveTowardLocation(const FVector& TargetLocation, float MovementSpeed);
void EnterDeadState();
AAgrarianGameCharacter* FindNearestPlayer(float Radius) const;
float GetNearestPlayerDistanceSquared() const;
bool Harvest(AAgrarianGameCharacter* Interactor);
void BroadcastHealthChanged();
void BroadcastStateChanged();
@@ -134,4 +145,5 @@ protected:
bool bHasNavigationMoveTarget = false;
float DecisionTimer = 0.0f;
float ServerThinkAccumulator = 0.0f;
};