diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 42475fe..5760f7f 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -686,7 +686,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add damage. - [x] Add harvesting interaction. - [x] Add meat/hide resources. -- [ ] Add spawn manager. +- [x] Add spawn manager. Added a replicated, server-authoritative wildlife + spawn manager actor with configurable wildlife class, initial spawn count, + max active population, spawn radius, respawn interval, and optional navigation + projection for MVP wildlife population control. - [x] Add replication. - [ ] Add performance limits. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 6337860..85c3c34 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -542,6 +542,14 @@ tile has no nav data, wildlife falls back to direct movement input so damage, flee, chase, and harvest loops remain functional while map navigation is being authored. +### Wildlife Spawning + +`AAgrarianWildlifeSpawnManager` owns MVP wildlife population seeding on the +server. Designers can assign a wildlife class, initial count, max active count, +spawn radius, and respawn interval. Spawn points optionally project to navigation +before spawning so the first wildlife prototype favors reachable positions while +still falling back cleanly on maps without complete nav data. + ### JSON Metadata Use JSON files for external terrain/tile pipeline metadata while the pipeline is diff --git a/Scripts/verify_wildlife_spawn_manager.py b/Scripts/verify_wildlife_spawn_manager.py new file mode 100644 index 0000000..0847f25 --- /dev/null +++ b/Scripts/verify_wildlife_spawn_manager.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Validate MVP wildlife spawn manager 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(haystack: str, needle: str, context: str) -> None: + if needle not in haystack: + raise AssertionError(f"Missing {needle!r} in {context}") + + +def main() -> int: + errors: list[str] = [] + checks = { + "Source/AgrarianGame/AgrarianWildlifeSpawnManager.h": [ + "class AAgrarianWildlifeSpawnManager", + "TSubclassOf WildlifeClass", + "InitialSpawnCount", + "MaxActiveWildlife", + "SpawnRadius", + "SpawnIntervalSeconds", + "bProjectSpawnsToNavigation", + "SpawnWildlife()", + "GetActiveWildlifeCount()", + "TArray> SpawnedWildlife", + ], + "Source/AgrarianGame/AgrarianWildlifeSpawnManager.cpp": [ + "bReplicates = true", + "DOREPLIFETIME(AAgrarianWildlifeSpawnManager, SpawnedWildlife)", + "HasAuthority()", + "SpawnInitialWildlife()", + "GetActiveWildlifeCount() >= MaxActiveWildlife", + "ProjectPointToNavigation", + "SpawnActor", + "AdjustIfPossibleButAlwaysSpawn", + "RemoveInvalidSpawnedWildlife()", + ], + "AGRARIAN_DEVELOPMENT_ROADMAP.md": [ + "[x] Add spawn manager.", + "server-authoritative wildlife", + "max active population", + ], + "Docs/TechnicalDesignDocument.md": [ + "Wildlife Spawning", + "AAgrarianWildlifeSpawnManager", + "spawn radius", + "respawn interval", + ], + } + + 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 spawn manager is wired and documented.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Source/AgrarianGame/AgrarianWildlifeSpawnManager.cpp b/Source/AgrarianGame/AgrarianWildlifeSpawnManager.cpp new file mode 100644 index 0000000..f34f8fe --- /dev/null +++ b/Source/AgrarianGame/AgrarianWildlifeSpawnManager.cpp @@ -0,0 +1,151 @@ +// Copyright Pacificao. All Rights Reserved. + +#include "AgrarianWildlifeSpawnManager.h" +#include "AgrarianWildlifeBase.h" +#include "NavigationSystem.h" +#include "Net/UnrealNetwork.h" + +AAgrarianWildlifeSpawnManager::AAgrarianWildlifeSpawnManager() +{ + PrimaryActorTick.bCanEverTick = true; + bReplicates = true; + SetReplicatingMovement(false); +} + +void AAgrarianWildlifeSpawnManager::BeginPlay() +{ + Super::BeginPlay(); + + SpawnTimer = SpawnIntervalSeconds; + if (HasAuthority() && bSpawnOnBeginPlay) + { + SpawnInitialWildlife(); + } +} + +void AAgrarianWildlifeSpawnManager::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + + if (!HasAuthority() || !WildlifeClass) + { + return; + } + + RemoveInvalidSpawnedWildlife(); + if (GetActiveWildlifeCount() >= MaxActiveWildlife) + { + return; + } + + SpawnTimer -= DeltaSeconds; + if (SpawnTimer <= 0.0f) + { + SpawnTimer = SpawnIntervalSeconds; + SpawnWildlife(); + } +} + +void AAgrarianWildlifeSpawnManager::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const +{ + Super::GetLifetimeReplicatedProps(OutLifetimeProps); + + DOREPLIFETIME(AAgrarianWildlifeSpawnManager, SpawnedWildlife); +} + +AAgrarianWildlifeBase* AAgrarianWildlifeSpawnManager::SpawnWildlife() +{ + if (!HasAuthority() || !WildlifeClass || GetActiveWildlifeCount() >= MaxActiveWildlife) + { + return nullptr; + } + + FVector SpawnLocation = GetActorLocation(); + if (!ChooseSpawnLocation(SpawnLocation)) + { + return nullptr; + } + + FActorSpawnParameters SpawnParameters; + SpawnParameters.Owner = this; + SpawnParameters.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; + + AAgrarianWildlifeBase* SpawnedActor = GetWorld()->SpawnActor( + WildlifeClass, + SpawnLocation, + FRotator::ZeroRotator, + SpawnParameters); + + if (SpawnedActor) + { + SpawnedWildlife.Add(SpawnedActor); + } + + return SpawnedActor; +} + +int32 AAgrarianWildlifeSpawnManager::GetActiveWildlifeCount() const +{ + int32 ActiveCount = 0; + for (AAgrarianWildlifeBase* Wildlife : SpawnedWildlife) + { + if (IsValid(Wildlife)) + { + ++ActiveCount; + } + } + + return ActiveCount; +} + +void AAgrarianWildlifeSpawnManager::SpawnInitialWildlife() +{ + const int32 TargetCount = FMath::Min(InitialSpawnCount, MaxActiveWildlife); + for (int32 SpawnIndex = GetActiveWildlifeCount(); SpawnIndex < TargetCount; ++SpawnIndex) + { + if (!SpawnWildlife()) + { + break; + } + } +} + +bool AAgrarianWildlifeSpawnManager::ChooseSpawnLocation(FVector& OutLocation) const +{ + const FVector RandomOffset( + FMath::FRandRange(-SpawnRadius, SpawnRadius), + FMath::FRandRange(-SpawnRadius, SpawnRadius), + 0.0f); + const FVector CandidateLocation = GetActorLocation() + RandomOffset; + + if (!bProjectSpawnsToNavigation) + { + OutLocation = CandidateLocation; + return true; + } + + const UWorld* World = GetWorld(); + const UNavigationSystemV1* NavigationSystem = World ? FNavigationSystem::GetCurrent(World) : nullptr; + if (!NavigationSystem) + { + OutLocation = CandidateLocation; + return true; + } + + FNavLocation ProjectedLocation; + if (!NavigationSystem->ProjectPointToNavigation(CandidateLocation, ProjectedLocation, NavigationProjectionExtent)) + { + return false; + } + + OutLocation = ProjectedLocation.Location; + return true; +} + +void AAgrarianWildlifeSpawnManager::RemoveInvalidSpawnedWildlife() +{ + SpawnedWildlife.RemoveAll([](const TObjectPtr& Wildlife) + { + return !IsValid(Wildlife); + }); +} diff --git a/Source/AgrarianGame/AgrarianWildlifeSpawnManager.h b/Source/AgrarianGame/AgrarianWildlifeSpawnManager.h new file mode 100644 index 0000000..3134f21 --- /dev/null +++ b/Source/AgrarianGame/AgrarianWildlifeSpawnManager.h @@ -0,0 +1,62 @@ +// Copyright Pacificao. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "AgrarianWildlifeSpawnManager.generated.h" + +class AAgrarianWildlifeBase; + +UCLASS(Blueprintable) +class AAgrarianWildlifeSpawnManager : public AActor +{ + GENERATED_BODY() + +public: + AAgrarianWildlifeSpawnManager(); + + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning") + TSubclassOf WildlifeClass; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning", meta = (ClampMin = "0")) + int32 InitialSpawnCount = 3; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning", meta = (ClampMin = "0")) + int32 MaxActiveWildlife = 6; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning", meta = (ClampMin = "0")) + float SpawnRadius = 3500.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning", meta = (ClampMin = "0.1")) + float SpawnIntervalSeconds = 20.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning") + bool bSpawnOnBeginPlay = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning") + bool bProjectSpawnsToNavigation = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Spawning") + FVector NavigationProjectionExtent = FVector(650.0f, 650.0f, 900.0f); + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Wildlife|Spawning") + AAgrarianWildlifeBase* SpawnWildlife(); + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Wildlife|Spawning") + int32 GetActiveWildlifeCount() const; + +protected: + void SpawnInitialWildlife(); + bool ChooseSpawnLocation(FVector& OutLocation) const; + void RemoveInvalidSpawnedWildlife(); + + UPROPERTY(Replicated) + TArray> SpawnedWildlife; + + float SpawnTimer = 0.0f; +};