diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 4d38b1c..a152093 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -459,7 +459,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Replace grey-box environment presentation with an MVP natural environment pass: terrain material, grass, shrubs, bushes, trees, water-source visuals, and clearer Ground Zero biome dressing. Added repeatable Ground Zero environment material generation, applied terrain/foliage/resource/water materials in the map setup, regenerated the demo map, documented the pass, and added verification for material assets plus map assignments. - [x] Add first-pass environment asset variation so trees, bushes, grass, resource nodes, and water do not read as repeated placeholders. Added repeatable labeled variation actors for tree canopies/trunks, rounded bushes, grass mats, rock slabs, and a freshwater surface using multiple prototype mesh silhouettes, unique scales, rotations, and Ground Zero material families; extended the natural-environment verifier to require variation coverage. - [x] Replace `LevelPrototyping` cube/cylinder mesh dependencies in Agrarian setup scripts and prototype Blueprints with Agrarian-native placeholder environment meshes. Added repeatable Agrarian-native placeholder mesh assets under `/Game/Agrarian/Environment/PlaceholderMeshes`, updated playable Blueprint and Ground Zero setup scripts to use those native paths, regenerated affected Blueprints/map content, and added verification that Blueprint, foliage, and variation meshes no longer point at template mesh paths. -- [ ] Add weather exposure zones if needed. +- [x] Add weather exposure zones if needed. Added native `AAgrarianWeatherExposureZone` volumes with exposure multipliers and temperature offsets, wired survival to apply the strongest overlapping zone after shelter protection, exposed current zone effects on the dev HUD, placed three Ground Zero zones for ridge, coastal-wind, and drainage cooling cases, and added verification for zone placement plus docs. - [ ] Add landmark or ruin placeholder. - [ ] Add spawn area with validation that the player spawns above sea level, above terrain by a safe offset, away from water, away from steep slopes, away from dense resource clusters, and with a known safe fallback coordinate. - [ ] Add performance profiling markers. diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index a528137..da3a925 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:71bc16dc0b387ea9dc6d6fc9cbff5e1da88d33937345ce3f19a47f425e9e7371 -size 7468900 +oid sha256:03a4def43cc9c7b3a45df87ac52e04fc9ec31beabde3ddc36a79ce4bcabf46f1 +size 7475907 diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 1e6c2d9..945803d 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -155,6 +155,15 @@ percentage, and trend the care-history shelter quality field toward the active protection level. The dev HUD shows current shelter protection so weather pressure can be tuned during MVP tests. +Weather exposure zones use `AAgrarianWeatherExposureZone` volumes placed by the +Ground Zero setup script. They describe local ridge, coastal-wind, and drainage +cooling effects with an exposure multiplier and temperature offset. Server-side +survival ticks select the strongest overlapping zone effect, replicate the +current multiplier and offset, then apply them to ambient body-temperature drift +and cold damage after shelter protection. This lets future generated tiles add +biome, slope, elevation, hydrology, and coastal modifiers without changing the +core survival calculation. + 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 92bbaff..df87c11 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -258,6 +258,36 @@ WATER_SOURCE_ACTORS = [ }, ] +WEATHER_EXPOSURE_ZONES = [ + { + "label": "AGR_GZ_WeatherExposure_Ridge_01", + "zone_id": "ground_zero_ridge_exposed", + "location_xy": unreal.Vector(36000.0, 29200.0, 0.0), + "z_offset": 450.0, + "extent": unreal.Vector(11500.0, 9200.0, 2200.0), + "exposure_multiplier": 1.45, + "temperature_offset_c": -1.8, + }, + { + "label": "AGR_GZ_WeatherExposure_CoastalWind_01", + "zone_id": "ground_zero_coastal_wind", + "location_xy": unreal.Vector(-31500.0, -14600.0, 0.0), + "z_offset": 350.0, + "extent": unreal.Vector(10500.0, 7600.0, 1600.0), + "exposure_multiplier": 1.25, + "temperature_offset_c": -0.9, + }, + { + "label": "AGR_GZ_WeatherExposure_DrainageCool_01", + "zone_id": "ground_zero_drainage_cool", + "location_xy": unreal.Vector(-7200.0, 10400.0, 0.0), + "z_offset": 260.0, + "extent": unreal.Vector(7200.0, 5400.0, 1100.0), + "exposure_multiplier": 1.1, + "temperature_offset_c": -1.2, + }, +] + ENVIRONMENT_VARIATION_ACTORS = [ { "label": "AGR_GZ_EnvVar_Tree_Canopy_01", @@ -690,6 +720,30 @@ def spawn_environment_variation_actor(spec, height_values, materials): return actor +def spawn_weather_exposure_zone(spec, height_values): + location_xy = spec["location_xy"] + z = terrain_z_cm(height_values, location_xy.x, location_xy.y) + spec.get("z_offset", 0.0) + actor = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world( + unreal.AgrarianWeatherExposureZone, + unreal.Vector(location_xy.x, location_xy.y, z), + unreal.Rotator(0.0, 0.0, 0.0), + spec["label"], + ) + if not actor: + raise RuntimeError(f"Could not spawn {spec['label']}") + + set_actor_label(actor, spec["label"]) + actor.set_editor_property("exposure_zone_id", spec["zone_id"]) + actor.set_editor_property("exposure_multiplier", spec["exposure_multiplier"]) + actor.set_editor_property("temperature_offset_c", spec["temperature_offset_c"]) + actor.exposure_volume.set_box_extent(spec["extent"], True) + unreal.log( + f"Placed {spec['label']} at {actor.get_actor_location()} " + f"exposure x{spec['exposure_multiplier']} temp {spec['temperature_offset_c']} C" + ) + return actor + + def main(): if not unreal.EditorLevelLibrary.load_level(MAP_PATH): raise RuntimeError(f"Could not load map: {MAP_PATH}") @@ -700,6 +754,7 @@ def main(): labels.update(LEGACY_DEMO_LIGHTING_LABELS) labels.update(spec["label"] for spec in BIOME_RESOURCE_ACTORS) labels.update(spec["label"] for spec in WATER_SOURCE_ACTORS) + labels.update(spec["label"] for spec in WEATHER_EXPOSURE_ZONES) labels.update(spec["label"] for spec in ENVIRONMENT_VARIATION_ACTORS) labels.add(FOLIAGE_LABEL) remove_existing_demo_actors(labels) @@ -712,6 +767,8 @@ def main(): spawn_demo_actor(spec, height_values, materials) for spec in WATER_SOURCE_ACTORS: spawn_demo_actor(spec, height_values, materials) + for spec in WEATHER_EXPOSURE_ZONES: + spawn_weather_exposure_zone(spec, height_values) for spec in ENVIRONMENT_VARIATION_ACTORS: spawn_environment_variation_actor(spec, height_values, materials) for spec in DEMO_ACTORS: diff --git a/Scripts/verify_weather_exposure_zones.py b/Scripts/verify_weather_exposure_zones.py new file mode 100644 index 0000000..fc579f5 --- /dev/null +++ b/Scripts/verify_weather_exposure_zones.py @@ -0,0 +1,96 @@ +import unreal + + +MAP_PATH = "/Game/Agrarian/Maps/L_GroundZeroTerrain_Test" +EXPECTED_ZONES = { + "AGR_GZ_WeatherExposure_Ridge_01": { + "zone_id": "ground_zero_ridge_exposed", + "exposure_multiplier_min": 1.4, + "temperature_offset_max": -1.0, + }, + "AGR_GZ_WeatherExposure_CoastalWind_01": { + "zone_id": "ground_zero_coastal_wind", + "exposure_multiplier_min": 1.2, + "temperature_offset_max": -0.5, + }, + "AGR_GZ_WeatherExposure_DrainageCool_01": { + "zone_id": "ground_zero_drainage_cool", + "exposure_multiplier_min": 1.05, + "temperature_offset_max": -0.5, + }, +} + + +def get_actor_label(actor): + try: + return actor.get_actor_label() + except Exception: + return actor.get_name() + + +def main(): + if not unreal.EditorLevelLibrary.load_level(MAP_PATH): + raise RuntimeError(f"Could not load map: {MAP_PATH}") + + failures = [] + actors = unreal.EditorLevelLibrary.get_all_level_actors() + zones_by_label = { + get_actor_label(actor): actor + for actor in actors + if isinstance(actor, unreal.AgrarianWeatherExposureZone) + } + + if len(zones_by_label) != len(EXPECTED_ZONES): + failures.append(f"expected {len(EXPECTED_ZONES)} weather exposure zones, found {len(zones_by_label)}") + + for label, expected in EXPECTED_ZONES.items(): + zone = zones_by_label.get(label) + if not zone: + failures.append(f"missing weather exposure zone: {label}") + continue + + zone_id = str(zone.get_editor_property("exposure_zone_id")) + if zone_id != expected["zone_id"]: + failures.append(f"{label} zone id expected {expected['zone_id']}, got {zone_id}") + + exposure_multiplier = zone.get_editor_property("exposure_multiplier") + if exposure_multiplier < expected["exposure_multiplier_min"]: + failures.append( + f"{label} exposure multiplier expected >= {expected['exposure_multiplier_min']}, got {exposure_multiplier}" + ) + + temperature_offset_c = zone.get_editor_property("temperature_offset_c") + if temperature_offset_c > expected["temperature_offset_max"]: + failures.append( + f"{label} temperature offset expected <= {expected['temperature_offset_max']}, got {temperature_offset_c}" + ) + + if not zone.exposure_volume: + failures.append(f"{label} has no exposure volume") + continue + + extent = zone.exposure_volume.get_unscaled_box_extent() + if min(extent.x, extent.y, extent.z) <= 0.0: + failures.append(f"{label} has invalid exposure volume extent {extent}") + + roadmap = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()) + "AGRARIAN_DEVELOPMENT_ROADMAP.md" + technical_design = unreal.Paths.convert_relative_path_to_full(unreal.Paths.project_dir()) + "Docs/TechnicalDesignDocument.md" + for path, snippet in [ + (roadmap, "[x] Add weather exposure zones if needed"), + (technical_design, "Weather exposure zones"), + ]: + with open(path, "r", encoding="utf-8") as handle: + text = handle.read() + if snippet not in text: + failures.append(f"{path} missing `{snippet}`") + + if failures: + raise RuntimeError("Weather exposure zone verification failed: " + "; ".join(failures)) + + unreal.log( + "Weather exposure zone verification complete: " + f"{len(zones_by_label)} zones with exposure multipliers and temperature offsets." + ) + + +main() diff --git a/Source/AgrarianGame/AgrarianDebugHUD.cpp b/Source/AgrarianGame/AgrarianDebugHUD.cpp index 2936f19..5bbc90f 100644 --- a/Source/AgrarianGame/AgrarianDebugHUD.cpp +++ b/Source/AgrarianGame/AgrarianDebugHUD.cpp @@ -103,6 +103,7 @@ void AAgrarianDebugHUD::DrawCriticalStats(const UAgrarianSurvivalComponent* Surv DrawScaledLine(FString::Printf(TEXT("Water %3.0f"), Survival.Thirst), X, Y, CriticalStatsTextScale, StatusColor(Survival.Thirst)); DrawScaledLine(FString::Printf(TEXT("Temp %4.1f C"), Survival.BodyTemperature), X, Y, CriticalStatsTextScale, Survival.BodyTemperature < 35.0f ? CriticalColor : StableColor); DrawScaledLine(FString::Printf(TEXT("Shelter %3.0f%%"), SurvivalComponent->CurrentWeatherProtection * 100.0f), X, Y, CriticalStatsTextScale, SurvivalComponent->CurrentWeatherProtection > 0.0f ? StableColor : WarningColor); + DrawScaledLine(FString::Printf(TEXT("Expose x%.2f %+3.1f C"), SurvivalComponent->CurrentWeatherExposureMultiplier, SurvivalComponent->CurrentWeatherTemperatureOffsetC), X, Y, CriticalStatsTextScale, SurvivalComponent->CurrentWeatherExposureMultiplier > 1.0f ? WarningColor : StableColor); DrawScaledLine(FString::Printf(TEXT("Exhaust %3.0f"), Survival.Exhaustion), X, Y, CriticalStatsTextScale, StatusColor(Survival.Exhaustion, true)); DrawScaledLine(FString::Printf(TEXT("Injury %3.0f"), Survival.InjurySeverity), X, Y, CriticalStatsTextScale, StatusColor(Survival.InjurySeverity, true)); DrawScaledLine(FString::Printf(TEXT("Sickness %3.0f"), Survival.SicknessSeverity), X, Y, CriticalStatsTextScale, StatusColor(Survival.SicknessSeverity, true)); @@ -173,6 +174,7 @@ void AAgrarianDebugHUD::DrawSurvival(const UAgrarianSurvivalComponent* SurvivalC DrawLine(FString::Printf(TEXT("Thirst: %.0f"), Survival.Thirst), X, Y); DrawLine(FString::Printf(TEXT("Temp: %.1f C"), Survival.BodyTemperature), X, Y); DrawLine(FString::Printf(TEXT("Shelter: %.0f%%"), SurvivalComponent->CurrentWeatherProtection * 100.0f), X, Y); + DrawLine(FString::Printf(TEXT("Expose: x%.2f %+3.1f C"), SurvivalComponent->CurrentWeatherExposureMultiplier, SurvivalComponent->CurrentWeatherTemperatureOffsetC), X, Y); DrawLine(FString::Printf(TEXT("Injury: %.0f"), Survival.InjurySeverity), X, Y); DrawLine(FString::Printf(TEXT("Sick: %.0f"), Survival.SicknessSeverity), X, Y); const FAgrarianCareHistorySnapshot& Care = SurvivalComponent->CareHistory; diff --git a/Source/AgrarianGame/AgrarianSurvivalComponent.cpp b/Source/AgrarianGame/AgrarianSurvivalComponent.cpp index a9a542e..96a82da 100644 --- a/Source/AgrarianGame/AgrarianSurvivalComponent.cpp +++ b/Source/AgrarianGame/AgrarianSurvivalComponent.cpp @@ -3,6 +3,7 @@ #include "AgrarianSurvivalComponent.h" #include "AgrarianGameState.h" #include "AgrarianShelterActor.h" +#include "AgrarianWeatherExposureZone.h" #include "Components/BoxComponent.h" #include "Engine/World.h" #include "Net/UnrealNetwork.h" @@ -35,6 +36,8 @@ void UAgrarianSurvivalComponent::TickComponent(float DeltaTime, ELevelTick TickT Survival.Thirst -= ThirstDecayPerMinute * Minutes; Survival.Stamina += StaminaRecoveryPerSecond * DeltaTime; CurrentWeatherProtection = CalculateCurrentWeatherProtection(); + CurrentWeatherExposureMultiplier = CalculateCurrentWeatherExposureMultiplier(); + CurrentWeatherTemperatureOffsetC = CalculateCurrentWeatherTemperatureOffsetC(); CareHistory.ShelterQuality = FMath::FInterpTo(CareHistory.ShelterQuality, CurrentWeatherProtection, DeltaTime, 0.02f); if (Survival.Stamina <= LowStaminaExhaustionThreshold) @@ -62,7 +65,8 @@ void UAgrarianSurvivalComponent::TickComponent(float DeltaTime, ELevelTick TickT if (const AAgrarianGameState* AgrarianGameState = World->GetGameState()) { const float ExposureProtectionMultiplier = 1.0f - FMath::Clamp(CurrentWeatherProtection, 0.0f, 1.0f); - const float ExposureDelta = (AgrarianGameState->AmbientTemperatureC - 18.0f) * 0.002f * DeltaTime * ExposureProtectionMultiplier; + const float EffectiveAmbientTemperatureC = AgrarianGameState->AmbientTemperatureC + CurrentWeatherTemperatureOffsetC; + const float ExposureDelta = (EffectiveAmbientTemperatureC - 18.0f) * 0.002f * DeltaTime * ExposureProtectionMultiplier * CurrentWeatherExposureMultiplier; Survival.BodyTemperature += FMath::Clamp(ExposureDelta, -0.035f, 0.02f); } } @@ -79,7 +83,7 @@ void UAgrarianSurvivalComponent::TickComponent(float DeltaTime, ELevelTick TickT if (Survival.BodyTemperature < 35.0f) { - Survival.Health -= ColdDamagePerMinute * Minutes * (1.0f - FMath::Clamp(CurrentWeatherProtection, 0.0f, 1.0f)); + Survival.Health -= ColdDamagePerMinute * Minutes * (1.0f - FMath::Clamp(CurrentWeatherProtection, 0.0f, 1.0f)) * CurrentWeatherExposureMultiplier; } if (Survival.SicknessSeverity >= 60.0f) @@ -98,6 +102,8 @@ void UAgrarianSurvivalComponent::GetLifetimeReplicatedProps(TArray OverlappingZoneActors; + Owner->GetOverlappingActors(OverlappingZoneActors, AAgrarianWeatherExposureZone::StaticClass()); + + float StrongestMultiplierDelta = 0.0f; + for (const AActor* Actor : OverlappingZoneActors) + { + const AAgrarianWeatherExposureZone* Zone = Cast(Actor); + if (!Zone || !Zone->ExposureVolume || !Zone->ExposureVolume->IsOverlappingActor(Owner)) + { + continue; + } + + const float ZoneDelta = FMath::Clamp(Zone->ExposureMultiplier, 0.0f, 3.0f) - 1.0f; + if (FMath::Abs(ZoneDelta) > FMath::Abs(StrongestMultiplierDelta)) + { + StrongestMultiplierDelta = ZoneDelta; + } + } + + return FMath::Clamp(1.0f + StrongestMultiplierDelta, 0.0f, 3.0f); +} + +float UAgrarianSurvivalComponent::CalculateCurrentWeatherTemperatureOffsetC() const +{ + const AActor* Owner = GetOwner(); + if (!Owner) + { + return 0.0f; + } + + TArray OverlappingZoneActors; + Owner->GetOverlappingActors(OverlappingZoneActors, AAgrarianWeatherExposureZone::StaticClass()); + + float StrongestOffset = 0.0f; + for (const AActor* Actor : OverlappingZoneActors) + { + const AAgrarianWeatherExposureZone* Zone = Cast(Actor); + if (!Zone || !Zone->ExposureVolume || !Zone->ExposureVolume->IsOverlappingActor(Owner)) + { + continue; + } + + const float ZoneOffset = FMath::Clamp(Zone->TemperatureOffsetC, -20.0f, 20.0f); + if (FMath::Abs(ZoneOffset) > FMath::Abs(StrongestOffset)) + { + StrongestOffset = ZoneOffset; + } + } + + return StrongestOffset; +} + void UAgrarianSurvivalComponent::OnRep_Survival() { BroadcastSurvivalChanged(); diff --git a/Source/AgrarianGame/AgrarianSurvivalComponent.h b/Source/AgrarianGame/AgrarianSurvivalComponent.h index 689b654..390d25f 100644 --- a/Source/AgrarianGame/AgrarianSurvivalComponent.h +++ b/Source/AgrarianGame/AgrarianSurvivalComponent.h @@ -33,6 +33,12 @@ public: UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|Survival|Shelter") float CurrentWeatherProtection = 0.0f; + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|Survival|Weather Exposure") + float CurrentWeatherExposureMultiplier = 1.0f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|Survival|Weather Exposure") + float CurrentWeatherTemperatureOffsetC = 0.0f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Survival|Rates", meta = (ClampMin = "0")) float HungerDecayPerMinute = 0.55f; @@ -108,6 +114,12 @@ public: UFUNCTION(BlueprintPure, Category = "Agrarian|Survival|Shelter") float CalculateCurrentWeatherProtection() const; + UFUNCTION(BlueprintPure, Category = "Agrarian|Survival|Weather Exposure") + float CalculateCurrentWeatherExposureMultiplier() const; + + UFUNCTION(BlueprintPure, Category = "Agrarian|Survival|Weather Exposure") + float CalculateCurrentWeatherTemperatureOffsetC() const; + protected: UFUNCTION() void OnRep_Survival(); diff --git a/Source/AgrarianGame/AgrarianWeatherExposureZone.cpp b/Source/AgrarianGame/AgrarianWeatherExposureZone.cpp new file mode 100644 index 0000000..4f00346 --- /dev/null +++ b/Source/AgrarianGame/AgrarianWeatherExposureZone.cpp @@ -0,0 +1,14 @@ +// Copyright Pacificao. All Rights Reserved. + +#include "AgrarianWeatherExposureZone.h" +#include "Components/BoxComponent.h" + +AAgrarianWeatherExposureZone::AAgrarianWeatherExposureZone() +{ + bReplicates = true; + + ExposureVolume = CreateDefaultSubobject(TEXT("ExposureVolume")); + RootComponent = ExposureVolume; + ExposureVolume->SetBoxExtent(FVector(500.0f, 500.0f, 250.0f)); + ExposureVolume->SetCollisionProfileName(TEXT("OverlapAllDynamic")); +} diff --git a/Source/AgrarianGame/AgrarianWeatherExposureZone.h b/Source/AgrarianGame/AgrarianWeatherExposureZone.h new file mode 100644 index 0000000..2334813 --- /dev/null +++ b/Source/AgrarianGame/AgrarianWeatherExposureZone.h @@ -0,0 +1,30 @@ +// Copyright Pacificao. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "AgrarianWeatherExposureZone.generated.h" + +class UBoxComponent; + +UCLASS(Blueprintable) +class AAgrarianWeatherExposureZone : public AActor +{ + GENERATED_BODY() + +public: + AAgrarianWeatherExposureZone(); + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Weather Exposure") + TObjectPtr ExposureVolume; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather Exposure", meta = (ClampMin = "0", ClampMax = "3")) + float ExposureMultiplier = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather Exposure", meta = (ClampMin = "-20", ClampMax = "20")) + float TemperatureOffsetC = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Weather Exposure") + FName ExposureZoneId = NAME_None; +};