Add wildlife navigation support

This commit is contained in:
2026-05-16 11:23:17 -07:00
parent 82463f3b99
commit 578220cf60
7 changed files with 295 additions and 12 deletions
+4 -1
View File
@@ -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 and Unreal Insights CPU scopes for authoritative game-state time/weather
ticking, survival ticking, sky-light refresh, weather-audio refresh, foliage ticking, survival ticking, sky-light refresh, weather-audio refresh, foliage
instance mutation, and weather-provider request/parse/fallback work. 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 map boundaries or soft limits.
- [ ] Add developer travel command. - [ ] Add developer travel command.
+10
View File
@@ -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. 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 ### JSON Metadata
Use JSON files for external terrain/tile pipeline metadata while the pipeline is Use JSON files for external terrain/tile pipeline metadata while the pipeline is
+11 -3
View File
@@ -3,7 +3,7 @@ import unreal
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test" MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
CHARACTER_CLASS_PATH = "/Game/Agrarian/Blueprints/Characters/BP_AgrarianPlayerCharacter" 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): def get_actor_label(actor):
@@ -27,6 +27,14 @@ def find_actor_by_label(label):
return None 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): def enum_name(value):
return str(value).split(".")[-1].lower() return str(value).split(".")[-1].lower()
@@ -45,9 +53,9 @@ def main():
raise RuntimeError(f"Could not load map: {MAP_PATH}") raise RuntimeError(f"Could not load map: {MAP_PATH}")
character_class = load_blueprint_class(CHARACTER_CLASS_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: 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 = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world(
character_class, character_class,
@@ -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())
@@ -15,6 +15,7 @@ public class AgrarianGame : ModuleRules
"InputCore", "InputCore",
"EnhancedInput", "EnhancedInput",
"AIModule", "AIModule",
"NavigationSystem",
"UMG", "UMG",
"Landscape", "Landscape",
"HTTP", "HTTP",
+162 -8
View File
@@ -3,14 +3,19 @@
#include "AgrarianWildlifeBase.h" #include "AgrarianWildlifeBase.h"
#include "AgrarianGameCharacter.h" #include "AgrarianGameCharacter.h"
#include "AgrarianInventoryComponent.h" #include "AgrarianInventoryComponent.h"
#include "AIController.h"
#include "GameFramework/CharacterMovementComponent.h" #include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/GameplayStatics.h" #include "Kismet/GameplayStatics.h"
#include "Navigation/PathFollowingComponent.h"
#include "NavigationSystem.h"
#include "Net/UnrealNetwork.h" #include "Net/UnrealNetwork.h"
AAgrarianWildlifeBase::AAgrarianWildlifeBase() AAgrarianWildlifeBase::AAgrarianWildlifeBase()
{ {
PrimaryActorTick.bCanEverTick = true; PrimaryActorTick.bCanEverTick = true;
bReplicates = true; bReplicates = true;
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
AIControllerClass = AAIController::StaticClass();
GetCharacterMovement()->MaxWalkSpeed = WanderSpeed; GetCharacterMovement()->MaxWalkSpeed = WanderSpeed;
GetCharacterMovement()->bOrientRotationToMovement = true; GetCharacterMovement()->bOrientRotationToMovement = true;
@@ -25,6 +30,10 @@ void AAgrarianWildlifeBase::BeginPlay()
SpawnLocation = GetActorLocation(); SpawnLocation = GetActorLocation();
Health = FMath::Clamp(Health, 0.0f, MaxHealth); Health = FMath::Clamp(Health, 0.0f, MaxHealth);
if (HasAuthority() && !GetController())
{
SpawnDefaultController();
}
ChooseWanderTarget(); ChooseWanderTarget();
BroadcastHealthChanged(); BroadcastHealthChanged();
BroadcastStateChanged(); BroadcastStateChanged();
@@ -159,34 +168,174 @@ void AAgrarianWildlifeBase::ServerThink(float DeltaSeconds)
void AAgrarianWildlifeBase::ChooseWanderTarget() void AAgrarianWildlifeBase::ChooseWanderTarget()
{ {
FVector ReachableTarget = FVector::ZeroVector;
if (ChooseReachableWanderTarget(ReachableTarget))
{
CurrentMoveTarget = ReachableTarget;
ClearNavigationMove();
return;
}
const FVector RandomOffset = FVector( const FVector RandomOffset = FVector(
FMath::FRandRange(-WanderRadius, WanderRadius), FMath::FRandRange(-WanderRadius, WanderRadius),
FMath::FRandRange(-WanderRadius, WanderRadius), FMath::FRandRange(-WanderRadius, WanderRadius),
0.0f); 0.0f);
CurrentMoveTarget = SpawnLocation + RandomOffset; CurrentMoveTarget = SpawnLocation + RandomOffset;
ClearNavigationMove();
} }
void AAgrarianWildlifeBase::MoveTowardTarget() void AAgrarianWildlifeBase::MoveTowardTarget()
{ {
FVector DesiredDirection = FVector::ZeroVector; FVector DesiredTarget = GetActorLocation();
float DesiredSpeed = WanderSpeed;
if (WildlifeState == EAgrarianWildlifeState::Fleeing && FocusActor) if (WildlifeState == EAgrarianWildlifeState::Fleeing && FocusActor)
{ {
DesiredDirection = GetActorLocation() - FocusActor->GetActorLocation(); FVector DesiredDirection = GetActorLocation() - FocusActor->GetActorLocation();
GetCharacterMovement()->MaxWalkSpeed = FleeSpeed; 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) else if (WildlifeState == EAgrarianWildlifeState::Chasing && FocusActor)
{ {
DesiredDirection = FocusActor->GetActorLocation() - GetActorLocation(); DesiredTarget = FocusActor->GetActorLocation();
GetCharacterMovement()->MaxWalkSpeed = FleeSpeed; DesiredSpeed = FleeSpeed;
} }
else if (WildlifeState == EAgrarianWildlifeState::Wandering) else if (WildlifeState == EAgrarianWildlifeState::Wandering)
{ {
DesiredDirection = CurrentMoveTarget - GetActorLocation(); DesiredTarget = CurrentMoveTarget;
GetCharacterMovement()->MaxWalkSpeed = WanderSpeed; 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<UNavigationSystemV1>(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<UNavigationSystemV1>(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<AAIController>(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; DesiredDirection.Z = 0.0f;
if (!DesiredDirection.IsNearlyZero()) if (!DesiredDirection.IsNearlyZero())
{ {
@@ -198,6 +347,11 @@ void AAgrarianWildlifeBase::EnterDeadState()
{ {
Health = 0.0f; Health = 0.0f;
SetWildlifeState(EAgrarianWildlifeState::Dead); SetWildlifeState(EAgrarianWildlifeState::Dead);
if (AAIController* AIController = Cast<AAIController>(GetController()))
{
AIController->StopMovement();
}
ClearNavigationMove();
GetCharacterMovement()->StopMovementImmediately(); GetCharacterMovement()->StopMovementImmediately();
GetCharacterMovement()->DisableMovement(); GetCharacterMovement()->DisableMovement();
SetLifeSpan(0.0f); SetLifeSpan(0.0f);
@@ -66,6 +66,18 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement", meta = (ClampMin = "0.1")) UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Movement", meta = (ClampMin = "0.1"))
float DecisionIntervalSeconds = 2.0f; 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") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Wildlife|Harvest")
TArray<FAgrarianItemStack> HarvestYields; TArray<FAgrarianItemStack> HarvestYields;
@@ -97,6 +109,11 @@ protected:
void ServerThink(float DeltaSeconds); void ServerThink(float DeltaSeconds);
void ChooseWanderTarget(); void ChooseWanderTarget();
void MoveTowardTarget(); 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(); void EnterDeadState();
AAgrarianGameCharacter* FindNearestPlayer(float Radius) const; AAgrarianGameCharacter* FindNearestPlayer(float Radius) const;
bool Harvest(AAgrarianGameCharacter* Interactor); bool Harvest(AAgrarianGameCharacter* Interactor);
@@ -112,5 +129,9 @@ protected:
UPROPERTY() UPROPERTY()
TObjectPtr<AActor> FocusActor; TObjectPtr<AActor> FocusActor;
UPROPERTY()
FVector LastNavigationMoveTarget = FVector::ZeroVector;
bool bHasNavigationMoveTarget = false;
float DecisionTimer = 0.0f; float DecisionTimer = 0.0f;
}; };