Add Ground Zero map boundary

This commit is contained in:
2026-05-16 12:36:58 -07:00
parent 578220cf60
commit 65bcdf639e
8 changed files with 366 additions and 3 deletions
+4 -1
View File
@@ -470,7 +470,10 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
controller, samples reachable wander points, projects chase/flee targets onto controller, samples reachable wander points, projects chase/flee targets onto
navmesh, requests server-authoritative AI movement when nav data exists, and navmesh, requests server-authoritative AI movement when nav data exists, and
retains direct movement fallback for early maps without nav data. retains direct movement fallback for early maps without nav data.
- [ ] Add map boundaries or soft limits. - [x] Add map boundaries or soft limits. Added native
`AAgrarianMapBoundaryVolume`, placed `AGR_GroundZeroMapBoundary` around the
loaded 1 km Ground Zero tile, clamps server-authoritative player pawns back
inside the tile with padding, and exposes a warning-zone hook for later UI.
- [ ] Add developer travel command. - [ ] Add developer travel command.
## 0.1.E Inventory System ## 0.1.E Inventory System
Binary file not shown.
+7
View File
@@ -173,6 +173,13 @@ rechecks the placed `AGR_DemoPlayerStart` against the same constraints so future
map, resource, water, or terrain changes cannot silently move the player below map, resource, water, or terrain changes cannot silently move the player below
sea level, into steep terrain, into water, or into a dense resource cluster. sea level, into steep terrain, into water, or into a dense resource cluster.
The Ground Zero map also contains `AGR_GroundZeroMapBoundary`, a native
`AAgrarianMapBoundaryVolume` centered on the 1 km x 1 km MVP tile. For the MVP,
the boundary clamps server-authoritative player pawns back inside the loaded
tile with a small padding rather than allowing players to walk into missing
neighbor terrain. The actor exposes a warning distance hook so later UI can
present an in-world or HUD notice before a clamp occurs.
First-pass sky and lighting use `AAgrarianSkyLightingController`. The controller First-pass sky and lighting use `AAgrarianSkyLightingController`. The controller
owns movable sun, skylight, and exponential-height-fog components and reads the owns movable sun, skylight, and exponential-height-fog components and reads the
replicated `AAgrarianGameState` time, active tile sunrise/sunset, weather state, replicated `AAgrarianGameState` time, active tile sunrise/sunset, weather state,
+34
View File
@@ -112,6 +112,15 @@ SAFE_SPAWN_CONFIG = {
], ],
} }
MAP_BOUNDARY_CONFIG = {
"label": "AGR_GroundZeroMapBoundary",
"boundary_id": "ground_zero_mvp_tile",
"location": unreal.Vector(0.0, 0.0, 10000.0),
"extent": unreal.Vector(50000.0, 50000.0, 25000.0),
"padding_cm": 250.0,
"warning_distance_cm": 3000.0,
}
DEMO_ACTORS = [ DEMO_ACTORS = [
{ {
@@ -880,6 +889,29 @@ def spawn_weather_exposure_zone(spec, height_values):
return actor return actor
def spawn_map_boundary_volume():
actor = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world(
unreal.AgrarianMapBoundaryVolume,
MAP_BOUNDARY_CONFIG["location"],
unreal.Rotator(0.0, 0.0, 0.0),
MAP_BOUNDARY_CONFIG["label"],
)
if not actor:
raise RuntimeError(f"Could not spawn {MAP_BOUNDARY_CONFIG['label']}")
set_actor_label(actor, MAP_BOUNDARY_CONFIG["label"])
actor.set_editor_property("boundary_id", MAP_BOUNDARY_CONFIG["boundary_id"])
actor.set_editor_property("boundary_padding_cm", MAP_BOUNDARY_CONFIG["padding_cm"])
actor.set_editor_property("warning_distance_cm", MAP_BOUNDARY_CONFIG["warning_distance_cm"])
actor.set_editor_property("clamp_players_at_boundary", True)
actor.boundary_volume.set_box_extent(MAP_BOUNDARY_CONFIG["extent"], True)
unreal.log(
f"Placed {MAP_BOUNDARY_CONFIG['label']} at {actor.get_actor_location()} "
f"extent {MAP_BOUNDARY_CONFIG['extent']}"
)
return actor
def main(): def main():
if not unreal.EditorLevelLibrary.load_level(MAP_PATH): if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}") raise RuntimeError(f"Could not load map: {MAP_PATH}")
@@ -894,6 +926,7 @@ def main():
labels.update(spec["label"] for spec in ENVIRONMENT_VARIATION_ACTORS) labels.update(spec["label"] for spec in ENVIRONMENT_VARIATION_ACTORS)
labels.update(spec["label"] for spec in RUIN_PLACEHOLDER_ACTORS) labels.update(spec["label"] for spec in RUIN_PLACEHOLDER_ACTORS)
labels.add(FOLIAGE_LABEL) labels.add(FOLIAGE_LABEL)
labels.add(MAP_BOUNDARY_CONFIG["label"])
remove_existing_demo_actors(labels) remove_existing_demo_actors(labels)
materials = ensure_environment_materials() materials = ensure_environment_materials()
@@ -911,6 +944,7 @@ def main():
spawn_environment_variation_actor(spec, height_values, materials) spawn_environment_variation_actor(spec, height_values, materials)
for spec in RUIN_PLACEHOLDER_ACTORS: for spec in RUIN_PLACEHOLDER_ACTORS:
spawn_environment_variation_actor(spec, height_values, materials) spawn_environment_variation_actor(spec, height_values, materials)
spawn_map_boundary_volume()
for spec in DEMO_ACTORS: for spec in DEMO_ACTORS:
spawn_demo_actor(spec, height_values, materials, safe_spawn_location_xy) spawn_demo_actor(spec, height_values, materials, safe_spawn_location_xy)
@@ -0,0 +1,82 @@
import unreal
MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test"
BOUNDARY_LABEL = "AGR_GroundZeroMapBoundary"
EXPECTED_BOUNDARY_ID = "ground_zero_mvp_tile"
EXPECTED_LOCATION = unreal.Vector(0.0, 0.0, 10000.0)
EXPECTED_EXTENT = unreal.Vector(50000.0, 50000.0, 25000.0)
EXPECTED_PADDING_CM = 250.0
EXPECTED_WARNING_DISTANCE_CM = 3000.0
TOLERANCE = 1.0
def get_actor_label(actor):
try:
return actor.get_actor_label()
except Exception:
return actor.get_name()
def nearly_equal(a, b, tolerance=TOLERANCE):
return abs(float(a) - float(b)) <= tolerance
def vectors_nearly_equal(a, b, tolerance=TOLERANCE):
return nearly_equal(a.x, b.x, tolerance) and nearly_equal(a.y, b.y, tolerance) and nearly_equal(a.z, b.z, tolerance)
def main():
if not unreal.EditorLevelLibrary.load_level(MAP_PATH):
raise RuntimeError(f"Could not load map: {MAP_PATH}")
boundaries = [
actor
for actor in unreal.EditorLevelLibrary.get_all_level_actors()
if isinstance(actor, unreal.AgrarianMapBoundaryVolume)
]
labeled_boundaries = [actor for actor in boundaries if get_actor_label(actor) == BOUNDARY_LABEL]
failures = []
if len(labeled_boundaries) != 1:
failures.append(f"expected one {BOUNDARY_LABEL}, found {len(labeled_boundaries)}")
if len(boundaries) != 1:
failures.append(f"expected one AgrarianMapBoundaryVolume actor, found {len(boundaries)}")
if labeled_boundaries:
boundary = labeled_boundaries[0]
if str(boundary.get_editor_property("boundary_id")) != EXPECTED_BOUNDARY_ID:
failures.append(f"boundary_id expected {EXPECTED_BOUNDARY_ID}, got {boundary.get_editor_property('boundary_id')}")
if not bool(boundary.get_editor_property("clamp_players_at_boundary")):
failures.append("clamp_players_at_boundary is disabled")
if not nearly_equal(boundary.get_editor_property("boundary_padding_cm"), EXPECTED_PADDING_CM):
failures.append("boundary_padding_cm mismatch")
if not nearly_equal(boundary.get_editor_property("warning_distance_cm"), EXPECTED_WARNING_DISTANCE_CM):
failures.append("warning_distance_cm mismatch")
if not vectors_nearly_equal(boundary.get_actor_location(), EXPECTED_LOCATION):
failures.append(f"boundary location expected {EXPECTED_LOCATION}, got {boundary.get_actor_location()}")
extent = boundary.boundary_volume.get_unscaled_box_extent()
if not vectors_nearly_equal(extent, EXPECTED_EXTENT):
failures.append(f"boundary extent expected {EXPECTED_EXTENT}, got {extent}")
inside_location = unreal.Vector(0.0, 0.0, 10000.0)
warning_location = unreal.Vector(48200.0, 0.0, 10000.0)
outside_location = unreal.Vector(52000.0, 0.0, 10000.0)
clamped_location = boundary.clamp_location_to_boundary(outside_location)
if boundary.is_location_outside_boundary(inside_location):
failures.append("inside test location incorrectly detected outside boundary")
if not boundary.is_location_inside_warning_zone(warning_location):
failures.append("warning-zone test location did not trigger warning zone")
if not boundary.is_location_outside_boundary(outside_location):
failures.append("outside test location did not trigger outside boundary")
if clamped_location.x > EXPECTED_EXTENT.x - EXPECTED_PADDING_CM + TOLERANCE:
failures.append(f"clamped X exceeds padded east boundary: {clamped_location.x}")
if failures:
raise RuntimeError("Ground Zero map boundary verification failed: " + "; ".join(failures))
unreal.log("Ground Zero map boundary verification complete.")
main()
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Validate native map boundary source and roadmap/doc 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:
header = read_text("Source/AgrarianGame/AgrarianMapBoundaryVolume.h")
for marker in [
"AAgrarianMapBoundaryVolume",
"BoundaryVolume",
"BoundaryId",
"bClampPlayersAtBoundary",
"BoundaryPaddingCm",
"WarningDistanceCm",
"IsLocationOutsideBoundary",
"IsLocationInsideWarningZone",
"ClampLocationToBoundary",
]:
require(marker, header, "AgrarianMapBoundaryVolume.h")
except AssertionError as exc:
errors.append(str(exc))
try:
source = read_text("Source/AgrarianGame/AgrarianMapBoundaryVolume.cpp")
for marker in [
"UGameplayStatics::GetAllActorsOfClass",
"AAgrarianGameCharacter::StaticClass()",
"TeleportTo",
"StopMovementImmediately",
"BoundaryVolume->SetBoxExtent(FVector(50000.0f, 50000.0f, 25000.0f))",
]:
require(marker, source, "AgrarianMapBoundaryVolume.cpp")
except AssertionError as exc:
errors.append(str(exc))
try:
setup = read_text("Scripts/setup_ground_zero_demo_map.py")
for marker in [
"MAP_BOUNDARY_CONFIG",
"AGR_GroundZeroMapBoundary",
"unreal.AgrarianMapBoundaryVolume",
"spawn_map_boundary_volume()",
]:
require(marker, setup, "Scripts/setup_ground_zero_demo_map.py")
except AssertionError as exc:
errors.append(str(exc))
try:
roadmap = read_text("AGRARIAN_DEVELOPMENT_ROADMAP.md")
require("[x] Add map boundaries or soft limits.", roadmap, "AGRARIAN_DEVELOPMENT_ROADMAP.md")
docs = read_text("Docs/TechnicalDesignDocument.md")
require("AGR_GroundZeroMapBoundary", docs, "Docs/TechnicalDesignDocument.md")
require("AAgrarianMapBoundaryVolume", 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("Map boundary source is wired.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,103 @@
// Copyright Pacificao. All Rights Reserved.
#include "AgrarianMapBoundaryVolume.h"
#include "AgrarianGameCharacter.h"
#include "Components/BoxComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/GameplayStatics.h"
AAgrarianMapBoundaryVolume::AAgrarianMapBoundaryVolume()
{
PrimaryActorTick.bCanEverTick = true;
bReplicates = false;
BoundaryVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("BoundaryVolume"));
RootComponent = BoundaryVolume;
BoundaryVolume->SetBoxExtent(FVector(50000.0f, 50000.0f, 25000.0f));
BoundaryVolume->SetCollisionEnabled(ECollisionEnabled::NoCollision);
BoundaryVolume->SetHiddenInGame(false);
BoundaryVolume->ShapeColor = FColor::Yellow;
}
void AAgrarianMapBoundaryVolume::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (!HasAuthority() || !bClampPlayersAtBoundary)
{
return;
}
TArray<AActor*> Characters;
UGameplayStatics::GetAllActorsOfClass(this, AAgrarianGameCharacter::StaticClass(), Characters);
for (AActor* Actor : Characters)
{
EnforceBoundaryForCharacter(Cast<AAgrarianGameCharacter>(Actor));
}
}
bool AAgrarianMapBoundaryVolume::IsLocationOutsideBoundary(const FVector& WorldLocation) const
{
if (!BoundaryVolume)
{
return false;
}
const FVector LocalLocation = GetActorTransform().InverseTransformPosition(WorldLocation);
const FVector Extent = BoundaryVolume->GetUnscaledBoxExtent();
return FMath::Abs(LocalLocation.X) > Extent.X
|| FMath::Abs(LocalLocation.Y) > Extent.Y
|| FMath::Abs(LocalLocation.Z) > Extent.Z;
}
bool AAgrarianMapBoundaryVolume::IsLocationInsideWarningZone(const FVector& WorldLocation) const
{
if (!BoundaryVolume)
{
return false;
}
const FVector LocalLocation = GetActorTransform().InverseTransformPosition(WorldLocation);
const FVector Extent = BoundaryVolume->GetUnscaledBoxExtent();
if (FMath::Abs(LocalLocation.X) > Extent.X || FMath::Abs(LocalLocation.Y) > Extent.Y)
{
return true;
}
return FMath::Abs(LocalLocation.X) >= Extent.X - WarningDistanceCm
|| FMath::Abs(LocalLocation.Y) >= Extent.Y - WarningDistanceCm;
}
FVector AAgrarianMapBoundaryVolume::ClampLocationToBoundary(const FVector& WorldLocation) const
{
if (!BoundaryVolume)
{
return WorldLocation;
}
const FVector Extent = BoundaryVolume->GetUnscaledBoxExtent();
const float SafePadding = FMath::Clamp(BoundaryPaddingCm, 0.0f, FMath::Min(Extent.X, Extent.Y) * 0.5f);
const FVector LocalLocation = GetActorTransform().InverseTransformPosition(WorldLocation);
const FVector ClampedLocalLocation(
FMath::Clamp(LocalLocation.X, -Extent.X + SafePadding, Extent.X - SafePadding),
FMath::Clamp(LocalLocation.Y, -Extent.Y + SafePadding, Extent.Y - SafePadding),
FMath::Clamp(LocalLocation.Z, -Extent.Z + SafePadding, Extent.Z - SafePadding));
return GetActorTransform().TransformPosition(ClampedLocalLocation);
}
void AAgrarianMapBoundaryVolume::EnforceBoundaryForCharacter(AAgrarianGameCharacter* Character) const
{
if (!Character || !IsLocationOutsideBoundary(Character->GetActorLocation()))
{
return;
}
const FVector ClampedLocation = ClampLocationToBoundary(Character->GetActorLocation());
Character->TeleportTo(ClampedLocation, Character->GetActorRotation(), false, true);
if (UCharacterMovementComponent* Movement = Character->GetCharacterMovement())
{
Movement->StopMovementImmediately();
}
}
@@ -0,0 +1,48 @@
// Copyright Pacificao. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "AgrarianMapBoundaryVolume.generated.h"
class AAgrarianGameCharacter;
class UBoxComponent;
UCLASS(Blueprintable)
class AGRARIANGAME_API AAgrarianMapBoundaryVolume : public AActor
{
GENERATED_BODY()
public:
AAgrarianMapBoundaryVolume();
virtual void Tick(float DeltaSeconds) override;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Map Boundary")
TObjectPtr<UBoxComponent> BoundaryVolume;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Map Boundary")
FName BoundaryId = TEXT("ground_zero_mvp_tile");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Map Boundary")
bool bClampPlayersAtBoundary = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Map Boundary", meta = (ClampMin = "0"))
float BoundaryPaddingCm = 250.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Map Boundary", meta = (ClampMin = "0"))
float WarningDistanceCm = 3000.0f;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Map Boundary")
bool IsLocationOutsideBoundary(const FVector& WorldLocation) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Map Boundary")
bool IsLocationInsideWarningZone(const FVector& WorldLocation) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|Map Boundary")
FVector ClampLocationToBoundary(const FVector& WorldLocation) const;
protected:
void EnforceBoundaryForCharacter(AAgrarianGameCharacter* Character) const;
};