diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 429f212..8acca44 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -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 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. +- [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. ## 0.1.E Inventory System diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index cb90407..55cccdb 100644 --- a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap +++ b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7225e51e1f563ce4117c01144ad3f6cf974e05f9f849237d4eae6a8f7d4cf477 -size 7483919 +oid sha256:580adc112b44b622efd99ca0f91668a19a678cfcc719b589688f2507daf00c79 +size 7485609 diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index c45bea7..ef9f32a 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -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 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 owns movable sun, skylight, and exponential-height-fog components and reads the replicated `AAgrarianGameState` time, active tile sunrise/sunset, weather state, diff --git a/Scripts/setup_ground_zero_demo_map.py b/Scripts/setup_ground_zero_demo_map.py index f22584e..7632e46 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -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 = [ { @@ -880,6 +889,29 @@ def spawn_weather_exposure_zone(spec, height_values): 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(): if not unreal.EditorLevelLibrary.load_level(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 RUIN_PLACEHOLDER_ACTORS) labels.add(FOLIAGE_LABEL) + labels.add(MAP_BOUNDARY_CONFIG["label"]) remove_existing_demo_actors(labels) materials = ensure_environment_materials() @@ -911,6 +944,7 @@ def main(): spawn_environment_variation_actor(spec, height_values, materials) for spec in RUIN_PLACEHOLDER_ACTORS: spawn_environment_variation_actor(spec, height_values, materials) + spawn_map_boundary_volume() for spec in DEMO_ACTORS: spawn_demo_actor(spec, height_values, materials, safe_spawn_location_xy) diff --git a/Scripts/verify_ground_zero_map_boundary.py b/Scripts/verify_ground_zero_map_boundary.py new file mode 100644 index 0000000..e80f9c6 --- /dev/null +++ b/Scripts/verify_ground_zero_map_boundary.py @@ -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() diff --git a/Scripts/verify_map_boundary_source.py b/Scripts/verify_map_boundary_source.py new file mode 100644 index 0000000..47cd263 --- /dev/null +++ b/Scripts/verify_map_boundary_source.py @@ -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()) diff --git a/Source/AgrarianGame/AgrarianMapBoundaryVolume.cpp b/Source/AgrarianGame/AgrarianMapBoundaryVolume.cpp new file mode 100644 index 0000000..84a2fe8 --- /dev/null +++ b/Source/AgrarianGame/AgrarianMapBoundaryVolume.cpp @@ -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(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 Characters; + UGameplayStatics::GetAllActorsOfClass(this, AAgrarianGameCharacter::StaticClass(), Characters); + for (AActor* Actor : Characters) + { + EnforceBoundaryForCharacter(Cast(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(); + } +} diff --git a/Source/AgrarianGame/AgrarianMapBoundaryVolume.h b/Source/AgrarianGame/AgrarianMapBoundaryVolume.h new file mode 100644 index 0000000..64d8135 --- /dev/null +++ b/Source/AgrarianGame/AgrarianMapBoundaryVolume.h @@ -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 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; +};