diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index eebf5bf..9108029 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -435,7 +435,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add weather save/load support. Added `LoadCurrentWorld` as the unified persistence load path, restored weather/time before players and world actors, updated the admin load command to use the combined path, and extended the persistence smoke test to prove provider-backed weather metadata survives save/load. - [x] Connect weather to body temperature. - [x] Connect shelter to weather protection. Survival now calculates the best overlapping shelter protection volume, replicates current weather protection, reduces ambient exposure and cold damage by shelter coverage, trends care-history shelter quality toward active protection, and shows shelter protection on the dev HUD. -- [ ] Add first-pass sky and lighting. +- [x] Add first-pass sky and lighting. Added `AAgrarianSkyLightingController` with movable sun, skylight, and fog components driven by replicated time, local sunrise/sunset, weather state, and provider cloud cover; updated the Ground Zero setup script to place the controller and remove legacy static lighting actors. - [ ] Add audio cues for weather. ## 0.1.D Single Biome MVP Map diff --git a/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap b/Content/Agrarian/Maps/L_GroundZeroTerrain_Test.umap index 4276dc6..62d88ac 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:b43e931908d398a485e33012e27b78c07bd30600bb5b5d0ac3439a77d2a8bb17 -size 7485494 +oid sha256:4b50fafbdcfb04a64be133adcdcb35d9e21a40ae0c7b762beb0b32ad8c8d6b65 +size 7482052 diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index 9023d1d..bf6f34c 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. +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, +and mapped cloud cover. It adjusts sun pitch, sun intensity/color, sky-light +intensity, and fog density every tick so the Ground Zero demo visually tracks +the represented local day/night cycle and current weather without hard-coded +static light settings. The Ground Zero map setup script places this controller +and removes the earlier static demo sun/skylight/fog actors. + The first real-weather adapter is `UAgrarianWeatherProviderSubsystem`. It uses Open-Meteo forecast requests keyed by tile center latitude/longitude, parses the current temperature, daily low/high, precipitation, wind, humidity, cloud cover, diff --git a/Scripts/setup_ground_zero_demo_map.py b/Scripts/setup_ground_zero_demo_map.py index 4ded187..8f1a29f 100644 --- a/Scripts/setup_ground_zero_demo_map.py +++ b/Scripts/setup_ground_zero_demo_map.py @@ -68,25 +68,11 @@ DEMO_ACTORS = [ "rotation": unreal.Rotator(0.0, 135.0, 0.0), }, { - "label": "AGR_DemoSun", - "class": unreal.DirectionalLight, - "location_xy": unreal.Vector(-22000.0, -9000.0, 0.0), - "fixed_z": 35000.0, - "rotation": unreal.Rotator(-42.0, -35.0, 0.0), - }, - { - "label": "AGR_DemoSkyLight", - "class": unreal.SkyLight, + "label": "AGR_DemoSkyLightingController", + "class": unreal.AgrarianSkyLightingController, "location_xy": unreal.Vector(-18000.0, -7000.0, 0.0), "fixed_z": 12000.0, - "rotation": unreal.Rotator(0.0, 0.0, 0.0), - }, - { - "label": "AGR_DemoFog", - "class": unreal.ExponentialHeightFog, - "location_xy": unreal.Vector(-18000.0, -7000.0, 0.0), - "fixed_z": 4000.0, - "rotation": unreal.Rotator(0.0, 0.0, 0.0), + "rotation": unreal.Rotator(-42.0, -35.0, 0.0), }, { "label": "AGR_DemoNoticeActor", @@ -97,6 +83,12 @@ DEMO_ACTORS = [ }, ] +LEGACY_DEMO_LIGHTING_LABELS = { + "AGR_DemoSun", + "AGR_DemoSkyLight", + "AGR_DemoFog", +} + BIOME_RESOURCE_ACTORS = [ { @@ -330,7 +322,7 @@ def spawn_foliage_actor(height_values): reserved_points = [ spec["location_xy"] for spec in DEMO_ACTORS - if spec["label"] not in {"AGR_DemoSun", "AGR_DemoSkyLight", "AGR_DemoFog", "AGR_DemoNoticeActor"} + if spec["label"] not in {"AGR_DemoSkyLightingController", "AGR_DemoNoticeActor"} ] foliage_actor = unreal.AgrarianEditorAutomationLibrary.spawn_actor_in_editor_world( @@ -405,6 +397,7 @@ def main(): raise RuntimeError(f"Could not load map: {MAP_PATH}") labels = {spec["label"] for spec in DEMO_ACTORS} + 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.add(FOLIAGE_LABEL) diff --git a/Scripts/verify_sky_lighting_controller.py b/Scripts/verify_sky_lighting_controller.py new file mode 100644 index 0000000..49efd24 --- /dev/null +++ b/Scripts/verify_sky_lighting_controller.py @@ -0,0 +1,66 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SKY_H = ROOT / "Source" / "AgrarianGame" / "AgrarianSkyLightingController.h" +SKY_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianSkyLightingController.cpp" +MAP_SETUP = ROOT / "Scripts" / "setup_ground_zero_demo_map.py" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + + +EXPECTED = { + SKY_H: [ + "class AAgrarianSkyLightingController : public AActor", + "TObjectPtr SunLight;", + "TObjectPtr SkyLight;", + "TObjectPtr HeightFog;", + "void RefreshSkyLighting();", + "float CalculateSunAlpha", + "float CalculateWeatherCloudAlpha", + ], + SKY_CPP: [ + "#include \"AgrarianGameState.h\"", + "SunLight = CreateDefaultSubobject", + "SkyLight = CreateDefaultSubobject", + "HeightFog = CreateDefaultSubobject", + "GameState->SunriseHourLocal", + "GameState->SunsetHourLocal", + "GameState->ActiveWeatherInputs.CloudCoverPercent", + "SunLight->SetWorldRotation", + "SunLight->SetIntensity", + "SkyLight->SetIntensity", + "HeightFog->SetFogDensity", + ], + MAP_SETUP: [ + "AGR_DemoSkyLightingController", + "unreal.AgrarianSkyLightingController", + "LEGACY_DEMO_LIGHTING_LABELS", + "\"AGR_DemoSun\"", + "\"AGR_DemoSkyLight\"", + "\"AGR_DemoFog\"", + ], + TDD: [ + "`AAgrarianSkyLightingController`", + "movable sun, skylight, and exponential-height-fog components", + ], + ROADMAP: [ + "[x] Add first-pass sky and lighting.", + ], +} + + +def main() -> None: + missing = [] + for path, snippets in EXPECTED.items(): + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + missing.append(f"{path.relative_to(ROOT)}: {snippet}") + if missing: + raise RuntimeError("Sky lighting controller verification failed: " + "; ".join(missing)) + print("Agrarian sky lighting controller verification complete.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianSkyLightingController.cpp b/Source/AgrarianGame/AgrarianSkyLightingController.cpp new file mode 100644 index 0000000..33327d5 --- /dev/null +++ b/Source/AgrarianGame/AgrarianSkyLightingController.cpp @@ -0,0 +1,147 @@ +// Copyright Pacificao. All Rights Reserved. + +#include "AgrarianSkyLightingController.h" + +#include "AgrarianGameState.h" +#include "Components/DirectionalLightComponent.h" +#include "Components/ExponentialHeightFogComponent.h" +#include "Components/SceneComponent.h" +#include "Components/SkyLightComponent.h" +#include "Engine/World.h" + +AAgrarianSkyLightingController::AAgrarianSkyLightingController() +{ + PrimaryActorTick.bCanEverTick = true; + bReplicates = false; + + SceneRoot = CreateDefaultSubobject(TEXT("SceneRoot")); + RootComponent = SceneRoot; + + SunLight = CreateDefaultSubobject(TEXT("SunLight")); + SunLight->SetupAttachment(SceneRoot); + SunLight->SetIntensity(NoonSunIntensity); + SunLight->SetLightColor(FLinearColor(1.0f, 0.96f, 0.86f)); + SunLight->SetMobility(EComponentMobility::Movable); + + SkyLight = CreateDefaultSubobject(TEXT("SkyLight")); + SkyLight->SetupAttachment(SceneRoot); + SkyLight->SetIntensity(ClearSkyLightIntensity); + SkyLight->SetMobility(EComponentMobility::Movable); + + HeightFog = CreateDefaultSubobject(TEXT("HeightFog")); + HeightFog->SetupAttachment(SceneRoot); + HeightFog->SetFogDensity(ClearFogDensity); + HeightFog->SetMobility(EComponentMobility::Movable); +} + +void AAgrarianSkyLightingController::BeginPlay() +{ + Super::BeginPlay(); + RefreshSkyLighting(); +} + +void AAgrarianSkyLightingController::Tick(float DeltaSeconds) +{ + Super::Tick(DeltaSeconds); + RefreshSkyLighting(); +} + +void AAgrarianSkyLightingController::RefreshSkyLighting() +{ + const UWorld* World = GetWorld(); + const AAgrarianGameState* GameState = World ? World->GetGameState() : nullptr; + if (!GameState) + { + return; + } + + const float SunriseHour = GameState->bHasActiveTileSolarData ? GameState->SunriseHourLocal : 6.0f; + const float SunsetHour = GameState->bHasActiveTileSolarData ? GameState->SunsetHourLocal : 20.0f; + CurrentSunAlpha = CalculateSunAlpha(GameState->WorldHours, SunriseHour, SunsetHour); + CurrentWeather = GameState->Weather; + CurrentCloudAlpha = CalculateWeatherCloudAlpha( + GameState->Weather, + GameState->ActiveWeatherInputs.CloudCoverPercent, + GameState->ActiveWeatherInputs.bHasProviderData); + + const float WeatherLightMultiplier = FMath::Lerp(1.0f, 0.35f, CurrentCloudAlpha); + const float SunIntensity = FMath::Lerp(NightSunIntensity, NoonSunIntensity, CurrentSunAlpha) * WeatherLightMultiplier; + const float SkyIntensity = FMath::Lerp(NightSkyLightIntensity, ClearSkyLightIntensity, CurrentSunAlpha) * FMath::Lerp(1.0f, 0.55f, CurrentCloudAlpha); + const float FogDensity = FMath::Lerp(ClearFogDensity, StormFogDensity, CurrentCloudAlpha); + const float SunPitch = FMath::Lerp(-8.0f, -72.0f, CurrentSunAlpha); + + if (SunLight) + { + SunLight->SetWorldRotation(FRotator(SunPitch, NorthYawDegrees, 0.0f)); + SunLight->SetIntensity(SunIntensity); + SunLight->SetLightColor(CalculateSunColor(CurrentSunAlpha, CurrentCloudAlpha)); + } + + if (SkyLight) + { + SkyLight->SetIntensity(SkyIntensity); + } + + if (HeightFog) + { + HeightFog->SetFogDensity(FogDensity); + } +} + +float AAgrarianSkyLightingController::CalculateSunAlpha(float HourOfDay, float SunriseHour, float SunsetHour) const +{ + const float NormalizedHour = FMath::Fmod(HourOfDay + 24.0f, 24.0f); + const float SafeSunrise = FMath::Fmod(SunriseHour + 24.0f, 24.0f); + const float SafeSunset = FMath::Fmod(SunsetHour + 24.0f, 24.0f); + const float DayLength = FMath::Max(0.1f, SafeSunset >= SafeSunrise ? SafeSunset - SafeSunrise : (24.0f - SafeSunrise) + SafeSunset); + + float HoursSinceSunrise = NormalizedHour - SafeSunrise; + if (HoursSinceSunrise < 0.0f) + { + HoursSinceSunrise += 24.0f; + } + + if (HoursSinceSunrise > DayLength) + { + return 0.0f; + } + + const float DayProgress = FMath::Clamp(HoursSinceSunrise / DayLength, 0.0f, 1.0f); + return FMath::Clamp(FMath::Sin(PI * DayProgress), 0.0f, 1.0f); +} + +float AAgrarianSkyLightingController::CalculateWeatherCloudAlpha(EAgrarianWeatherType Weather, float ProviderCloudCoverPercent, bool bHasProviderCloudCover) const +{ + float WeatherAlpha = 0.0f; + switch (Weather) + { + case EAgrarianWeatherType::Rain: + WeatherAlpha = 0.65f; + break; + case EAgrarianWeatherType::ColdWind: + WeatherAlpha = 0.45f; + break; + case EAgrarianWeatherType::Storm: + WeatherAlpha = 1.0f; + break; + default: + WeatherAlpha = 0.0f; + break; + } + + if (bHasProviderCloudCover) + { + WeatherAlpha = FMath::Max(WeatherAlpha, FMath::Clamp(ProviderCloudCoverPercent / 100.0f, 0.0f, 1.0f)); + } + + return WeatherAlpha; +} + +FLinearColor AAgrarianSkyLightingController::CalculateSunColor(float SunAlpha, float CloudAlpha) const +{ + const FLinearColor DawnColor(1.0f, 0.62f, 0.38f); + const FLinearColor NoonColor(1.0f, 0.96f, 0.86f); + const FLinearColor StormColor(0.52f, 0.58f, 0.66f); + const FLinearColor TimeColor = FLinearColor::LerpUsingHSV(DawnColor, NoonColor, FMath::Clamp(SunAlpha, 0.0f, 1.0f)); + return FLinearColor::LerpUsingHSV(TimeColor, StormColor, FMath::Clamp(CloudAlpha, 0.0f, 1.0f)); +} diff --git a/Source/AgrarianGame/AgrarianSkyLightingController.h b/Source/AgrarianGame/AgrarianSkyLightingController.h new file mode 100644 index 0000000..48edf45 --- /dev/null +++ b/Source/AgrarianGame/AgrarianSkyLightingController.h @@ -0,0 +1,75 @@ +// Copyright Pacificao. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "AgrarianTypes.h" +#include "AgrarianSkyLightingController.generated.h" + +class UDirectionalLightComponent; +class UExponentialHeightFogComponent; +class USceneComponent; +class USkyLightComponent; + +UCLASS(Blueprintable) +class AAgrarianSkyLightingController : public AActor +{ + GENERATED_BODY() + +public: + AAgrarianSkyLightingController(); + + virtual void BeginPlay() override; + virtual void Tick(float DeltaSeconds) override; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + TObjectPtr SceneRoot; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + TObjectPtr SunLight; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + TObjectPtr SkyLight; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + TObjectPtr HeightFog; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float NoonSunIntensity = 8.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float NightSunIntensity = 0.03f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float ClearSkyLightIntensity = 1.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float NightSkyLightIntensity = 0.08f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float ClearFogDensity = 0.008f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float StormFogDensity = 0.05f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Sky") + float NorthYawDegrees = -35.0f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + float CurrentSunAlpha = 0.0f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + float CurrentCloudAlpha = 0.0f; + + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Agrarian|Sky") + EAgrarianWeatherType CurrentWeather = EAgrarianWeatherType::Clear; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Sky") + void RefreshSkyLighting(); + +protected: + float CalculateSunAlpha(float HourOfDay, float SunriseHour, float SunsetHour) const; + float CalculateWeatherCloudAlpha(EAgrarianWeatherType Weather, float ProviderCloudCoverPercent, bool bHasProviderCloudCover) const; + FLinearColor CalculateSunColor(float SunAlpha, float CloudAlpha) const; +};