From 578220cf60dc777089a02ccbb9363df57690a392 Mon Sep 17 00:00:00 2001 From: nathan Date: Sat, 16 May 2026 11:23:17 -0700 Subject: [PATCH] Add wildlife navigation support --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 5 +- Docs/TechnicalDesignDocument.md | 10 ++ Scripts/verify_wildlife_loop.py | 14 +- Scripts/verify_wildlife_navigation_support.py | 86 +++++++++ Source/AgrarianGame/AgrarianGame.Build.cs | 1 + Source/AgrarianGame/AgrarianWildlifeBase.cpp | 170 +++++++++++++++++- Source/AgrarianGame/AgrarianWildlifeBase.h | 21 +++ 7 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 Scripts/verify_wildlife_navigation_support.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 8f28382..429f212 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -466,7 +466,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe and Unreal Insights CPU scopes for authoritative game-state time/weather ticking, survival ticking, sky-light refresh, weather-audio refresh, foliage instance mutation, and weather-provider request/parse/fallback work. -- [ ] Add navigation support for wildlife. +- [x] Add navigation support for wildlife. Wildlife now auto-possesses an AI + controller, samples reachable wander points, projects chase/flee targets onto + navmesh, requests server-authoritative AI movement when nav data exists, and + retains direct movement fallback for early maps without nav data. - [ ] Add map boundaries or soft limits. - [ ] Add developer travel command. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 7cfcd08..c45bea7 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -334,6 +334,16 @@ Use Unreal Data Assets for designer-facing definitions such as: Data Assets should describe content. Server code should enforce gameplay rules. +### Wildlife Navigation + +MVP wildlife movement is server authoritative. `AAgrarianWildlifeBase` uses an +AI controller and Unreal navigation when nav data exists: wander targets are +chosen from reachable nav points, chase/flee targets are projected onto navmesh, +and movement requests use `MoveToLocation`. If a test map or early generated +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. + ### JSON Metadata Use JSON files for external terrain/tile pipeline metadata while the pipeline is diff --git a/Scripts/verify_wildlife_loop.py b/Scripts/verify_wildlife_loop.py index 6b59b90..7027ba3 100644 --- a/Scripts/verify_wildlife_loop.py +++ b/Scripts/verify_wildlife_loop.py @@ -3,7 +3,7 @@ import unreal MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test" CHARACTER_CLASS_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianPlayerCharacter" -RABBIT_LABEL = "AGR_RabbitWildlife_01" +RABBIT_LABELS = ["AGR_DemoRabbitWildlife_01", "AGR_RabbitWildlife_01"] def get_actor_label(actor): @@ -27,6 +27,14 @@ def find_actor_by_label(label): return None +def find_actor_by_any_label(labels): + for label in labels: + actor = find_actor_by_label(label) + if actor: + return actor + return None + + def enum_name(value): return str(value).split(".")[-1].lower() @@ -45,9 +53,9 @@ def main(): raise RuntimeError(f"Could not load map: {MAP_PATH}") character_class = load_blueprint_class(CHARACTER_CLASS_PATH) - rabbit = find_actor_by_label(RABBIT_LABEL) + rabbit = find_actor_by_any_label(RABBIT_LABELS) if not rabbit: - raise RuntimeError(f"Could not find placed rabbit wildlife: {RABBIT_LABEL}") + raise RuntimeError(f"Could not find placed rabbit wildlife by labels: {RABBIT_LABELS}") character = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world( character_class, diff --git a/Scripts/verify_wildlife_navigation_support.py b/Scripts/verify_wildlife_navigation_support.py new file mode 100644 index 0000000..ee2c3bd --- /dev/null +++ b/Scripts/verify_wildlife_navigation_support.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Validate native wildlife navigation support wiring.""" + +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def read_text(relative_path: str) -> str: + path = REPO_ROOT / relative_path + if not path.exists(): + raise AssertionError(f"Missing required file: {relative_path}") + return path.read_text(encoding="utf-8") + + +def require(needle: str, haystack: str, context: str) -> None: + if needle not in haystack: + raise AssertionError(f"Missing {needle!r} in {context}") + + +def main() -> int: + errors: list[str] = [] + + try: + build_cs = read_text("Source/AgrarianGame/AgrarianGame.Build.cs") + require('"AIModule"', build_cs, "AgrarianGame.Build.cs") + require('"NavigationSystem"', build_cs, "AgrarianGame.Build.cs") + except AssertionError as exc: + errors.append(str(exc)) + + try: + header = read_text("Source/AgrarianGame/AgrarianWildlifeBase.h") + for marker in [ + "bUseNavigationMovement", + "NavigationAcceptanceRadius", + "NavigationRepathDistance", + "NavigationProjectionExtent", + "ChooseReachableWanderTarget", + "ProjectPointToNavigation", + "RequestNavigationMove", + "DirectMoveTowardLocation", + ]: + require(marker, header, "AgrarianWildlifeBase.h") + except AssertionError as exc: + errors.append(str(exc)) + + try: + source = read_text("Source/AgrarianGame/AgrarianWildlifeBase.cpp") + for marker in [ + '#include "AIController.h"', + '#include "NavigationSystem.h"', + "AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned", + "AIControllerClass = AAIController::StaticClass()", + "SpawnDefaultController()", + "GetRandomReachablePointInRadius", + "ProjectPointToNavigation", + "MoveToLocation", + "EPathFollowingRequestResult::Failed", + "StopMovement()", + "DirectMoveTowardLocation", + ]: + require(marker, source, "AgrarianWildlifeBase.cpp") + except AssertionError as exc: + errors.append(str(exc)) + + try: + roadmap = read_text("AGRARIAN_DEVELOPMENT_ROADMAP.md") + require("[x] Add navigation support for wildlife.", roadmap, "AGRARIAN_DEVELOPMENT_ROADMAP.md") + docs = read_text("Docs/TechnicalDesignDocument.md") + require("Wildlife Navigation", docs, "Docs/TechnicalDesignDocument.md") + require("falls back to direct movement input", docs, "Docs/TechnicalDesignDocument.md") + except AssertionError as exc: + errors.append(str(exc)) + + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + return 1 + + print("Wildlife navigation support is wired.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Source/AgrarianGame/AgrarianGame.Build.cs b/Source/AgrarianGame/AgrarianGame.Build.cs index 28fd23f..26d2bc8 100644 --- a/Source/AgrarianGame/AgrarianGame.Build.cs +++ b/Source/AgrarianGame/AgrarianGame.Build.cs @@ -15,6 +15,7 @@ public class AgrarianGame : ModuleRules "InputCore", "EnhancedInput", "AIModule", + "NavigationSystem", "UMG", "Landscape", "HTTP", diff --git a/Source/AgrarianGame/AgrarianWildlifeBase.cpp b/Source/AgrarianGame/AgrarianWildlifeBase.cpp index 9544655..47fd54c 100644 --- a/Source/AgrarianGame/AgrarianWildlifeBase.cpp +++ b/Source/AgrarianGame/AgrarianWildlifeBase.cpp @@ -3,14 +3,19 @@ #include "AgrarianWildlifeBase.h" #include "AgrarianGameCharacter.h" #include "AgrarianInventoryComponent.h" +#include "AIController.h" #include "GameFramework/CharacterMovementComponent.h" #include "Kismet/GameplayStatics.h" +#include "Navigation/PathFollowingComponent.h" +#include "NavigationSystem.h" #include "Net/UnrealNetwork.h" AAgrarianWildlifeBase::AAgrarianWildlifeBase() { PrimaryActorTick.bCanEverTick = true; bReplicates = true; + AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned; + AIControllerClass = AAIController::StaticClass(); GetCharacterMovement()->MaxWalkSpeed = WanderSpeed; GetCharacterMovement()->bOrientRotationToMovement = true; @@ -25,6 +30,10 @@ void AAgrarianWildlifeBase::BeginPlay() SpawnLocation = GetActorLocation(); Health = FMath::Clamp(Health, 0.0f, MaxHealth); + if (HasAuthority() && !GetController()) + { + SpawnDefaultController(); + } ChooseWanderTarget(); BroadcastHealthChanged(); BroadcastStateChanged(); @@ -159,34 +168,174 @@ void AAgrarianWildlifeBase::ServerThink(float DeltaSeconds) void AAgrarianWildlifeBase::ChooseWanderTarget() { + FVector ReachableTarget = FVector::ZeroVector; + if (ChooseReachableWanderTarget(ReachableTarget)) + { + CurrentMoveTarget = ReachableTarget; + ClearNavigationMove(); + return; + } + const FVector RandomOffset = FVector( FMath::FRandRange(-WanderRadius, WanderRadius), FMath::FRandRange(-WanderRadius, WanderRadius), 0.0f); - CurrentMoveTarget = SpawnLocation + RandomOffset; + ClearNavigationMove(); } void AAgrarianWildlifeBase::MoveTowardTarget() { - FVector DesiredDirection = FVector::ZeroVector; + FVector DesiredTarget = GetActorLocation(); + float DesiredSpeed = WanderSpeed; if (WildlifeState == EAgrarianWildlifeState::Fleeing && FocusActor) { - DesiredDirection = GetActorLocation() - FocusActor->GetActorLocation(); - GetCharacterMovement()->MaxWalkSpeed = FleeSpeed; + FVector DesiredDirection = GetActorLocation() - FocusActor->GetActorLocation(); + DesiredDirection.Z = 0.0f; + if (DesiredDirection.IsNearlyZero()) + { + DesiredDirection = GetActorForwardVector(); + DesiredDirection.Z = 0.0f; + } + + DesiredTarget = GetActorLocation() + DesiredDirection.GetSafeNormal() * FleeRadius; + DesiredSpeed = FleeSpeed; } else if (WildlifeState == EAgrarianWildlifeState::Chasing && FocusActor) { - DesiredDirection = FocusActor->GetActorLocation() - GetActorLocation(); - GetCharacterMovement()->MaxWalkSpeed = FleeSpeed; + DesiredTarget = FocusActor->GetActorLocation(); + DesiredSpeed = FleeSpeed; } else if (WildlifeState == EAgrarianWildlifeState::Wandering) { - DesiredDirection = CurrentMoveTarget - GetActorLocation(); - GetCharacterMovement()->MaxWalkSpeed = WanderSpeed; + DesiredTarget = CurrentMoveTarget; + DesiredSpeed = WanderSpeed; } + if (RequestNavigationMove(DesiredTarget)) + { + GetCharacterMovement()->MaxWalkSpeed = DesiredSpeed; + return; + } + + DirectMoveTowardLocation(DesiredTarget, DesiredSpeed); +} + +bool AAgrarianWildlifeBase::ChooseReachableWanderTarget(FVector& OutTarget) const +{ + if (!bUseNavigationMovement) + { + return false; + } + + const UWorld* World = GetWorld(); + const UNavigationSystemV1* NavigationSystem = World ? FNavigationSystem::GetCurrent(World) : nullptr; + if (!NavigationSystem) + { + return false; + } + + FNavLocation ReachableLocation; + if (!NavigationSystem->GetRandomReachablePointInRadius(SpawnLocation, WanderRadius, ReachableLocation)) + { + return false; + } + + OutTarget = ReachableLocation.Location; + return true; +} + +bool AAgrarianWildlifeBase::ProjectPointToNavigation(const FVector& CandidateLocation, FVector& OutProjectedLocation) const +{ + if (!bUseNavigationMovement) + { + return false; + } + + const UWorld* World = GetWorld(); + const UNavigationSystemV1* NavigationSystem = World ? FNavigationSystem::GetCurrent(World) : nullptr; + if (!NavigationSystem) + { + return false; + } + + FNavLocation ProjectedLocation; + if (!NavigationSystem->ProjectPointToNavigation(CandidateLocation, ProjectedLocation, NavigationProjectionExtent)) + { + return false; + } + + OutProjectedLocation = ProjectedLocation.Location; + return true; +} + +bool AAgrarianWildlifeBase::RequestNavigationMove(const FVector& TargetLocation) +{ + if (!bUseNavigationMovement) + { + return false; + } + + AAIController* AIController = Cast(GetController()); + if (!AIController) + { + return false; + } + + FVector ProjectedTarget = TargetLocation; + if (!ProjectPointToNavigation(TargetLocation, ProjectedTarget)) + { + return false; + } + + if (FVector::DistSquared(GetActorLocation(), ProjectedTarget) <= FMath::Square(NavigationAcceptanceRadius)) + { + AIController->StopMovement(); + ClearNavigationMove(); + return true; + } + + if (bHasNavigationMoveTarget + && AIController->GetMoveStatus() != EPathFollowingStatus::Idle + && FVector::DistSquared(LastNavigationMoveTarget, ProjectedTarget) <= FMath::Square(NavigationRepathDistance)) + { + return true; + } + + const EPathFollowingRequestResult::Type MoveResult = AIController->MoveToLocation( + ProjectedTarget, + NavigationAcceptanceRadius, + true, + true, + true, + true, + nullptr, + true); + + if (MoveResult == EPathFollowingRequestResult::Failed) + { + ClearNavigationMove(); + return false; + } + + LastNavigationMoveTarget = ProjectedTarget; + bHasNavigationMoveTarget = true; + return true; +} + +void AAgrarianWildlifeBase::ClearNavigationMove() +{ + bHasNavigationMoveTarget = false; + LastNavigationMoveTarget = FVector::ZeroVector; +} + +void AAgrarianWildlifeBase::DirectMoveTowardLocation(const FVector& TargetLocation, float MovementSpeed) +{ + ClearNavigationMove(); + GetCharacterMovement()->MaxWalkSpeed = MovementSpeed; + + FVector DesiredDirection = TargetLocation - GetActorLocation(); DesiredDirection.Z = 0.0f; if (!DesiredDirection.IsNearlyZero()) { @@ -198,6 +347,11 @@ void AAgrarianWildlifeBase::EnterDeadState() { Health = 0.0f; SetWildlifeState(EAgrarianWildlifeState::Dead); + if (AAIController* AIController = Cast(GetController())) + { + AIController->StopMovement(); + } + ClearNavigationMove(); GetCharacterMovement()->StopMovementImmediately(); GetCharacterMovement()->DisableMovement(); SetLifeSpan(0.0f); diff --git a/Source/AgrarianGame/AgrarianWildlifeBase.h b/Source/AgrarianGame/AgrarianWildlifeBase.h index 833f9fe..a5cc660 100644 --- a/Source/AgrarianGame/AgrarianWildlifeBase.h +++ b/Source/AgrarianGame/AgrarianWildlifeBase.h @@ -66,6 +66,18 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement", meta = (ClampMin = "0.1")) float DecisionIntervalSeconds = 2.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement") + bool bUseNavigationMovement = true; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement", meta = (ClampMin = "1")) + float NavigationAcceptanceRadius = 85.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement", meta = (ClampMin = "1")) + float NavigationRepathDistance = 175.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement") + FVector NavigationProjectionExtent = FVector(500.0f, 500.0f, 900.0f); + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Harvest") TArray HarvestYields; @@ -97,6 +109,11 @@ protected: void ServerThink(float DeltaSeconds); void ChooseWanderTarget(); void MoveTowardTarget(); + bool ChooseReachableWanderTarget(FVector& OutTarget) const; + bool ProjectPointToNavigation(const FVector& CandidateLocation, FVector& OutProjectedLocation) const; + bool RequestNavigationMove(const FVector& TargetLocation); + void ClearNavigationMove(); + void DirectMoveTowardLocation(const FVector& TargetLocation, float MovementSpeed); void EnterDeadState(); AAgrarianGameCharacter* FindNearestPlayer(float Radius) const; bool Harvest(AAgrarianGameCharacter* Interactor); @@ -112,5 +129,9 @@ protected: UPROPERTY() TObjectPtr FocusActor; + UPROPERTY() + FVector LastNavigationMoveTarget = FVector::ZeroVector; + + bool bHasNavigationMoveTarget = false; float DecisionTimer = 0.0f; };