diff --git a/Source/ProjectEleri/Items/SeedBedActor.cpp b/Source/ProjectEleri/Items/SeedBedActor.cpp index 1376921a..9a9cbdc6 100644 --- a/Source/ProjectEleri/Items/SeedBedActor.cpp +++ b/Source/ProjectEleri/Items/SeedBedActor.cpp @@ -4,6 +4,7 @@ #include "SeedBedActor.h" #include "EleriGameState.h" +#include "TimeOfDayManager.h" #include "Components/DecalComponent.h" #include "Components/SceneComponent.h" #include "Components/StaticMeshComponent.h" @@ -90,6 +91,12 @@ namespace return IsValid(Slot.SlotData.ItemDataAsset) || Slot.SlotData.ItemAssetID.IsValid(); } + /** A dead plant keeps its id until the slot is cleared, but it neither grows nor blocks the slot anymore */ + bool SlotHasLivingPlant(const FSeedBedSeedSlot& Slot) + { + return SlotHasPlant(Slot) && !Slot.SlotData.bDied; + } + /** Growth stages are set up by hand, so a zeroed scale should never make a plant invisible */ FVector SanitizeScale(const FVector& Scale) { @@ -126,18 +133,40 @@ void ASeedBedActor::BeginPlay() ClearSeedBedGrid(); GenerateSeedBedGrid(); - - AEleriGameState* GameState = Cast(UGameplayStatics::GetGameState(this)); - ensure(GameState); - - FSeedBedSaveData SaveData; - if (GameState->GetSeedData(SeedBedIndex, SaveData)) + + // The bed is streamed in and out together with its world partition cell, so it tracks the days itself + BindToTimeOfDay(); + CacheCurrentTimeOfDay(); + +#if WITH_EDITOR + // Beds are addressed by SeedBedIndex in the save data, two beds sharing one would overwrite each other + TArray OtherSeedBeds; + UGameplayStatics::GetAllActorsOfClass(this, ASeedBedActor::StaticClass(), OtherSeedBeds); + for (AActor* OtherSeedBed : OtherSeedBeds) { - PropagateSeedSaveData(SaveData); + const ASeedBedActor* OtherBed = Cast(OtherSeedBed); + if (OtherBed && OtherBed != this && OtherBed->SeedBedIndex == SeedBedIndex) + { + UE_LOG(LogTemp, Warning, TEXT("%s shares SeedBedIndex %d with %s, their save data overwrite each other"), + *GetName(), SeedBedIndex, *OtherBed->GetName()); + break; + } } - else +#endif + + if (AEleriGameState* GameState = Cast(UGameplayStatics::GetGameState(this))) { - GameState->CacheSeedData(SeedBedIndex, PrepareSeedSaveData()); + FSeedBedSaveData SaveData; + if (GameState->GetSeedData(SeedBedIndex, SaveData)) + { + // The bed was in the world before, so its plants have to catch up on the time that passed without it + PropagateSeedSaveData(SaveData); + } + else + { + LastUpdateTime = LastKnownTimeOfDay; + GameState->CacheSeedData(SeedBedIndex, PrepareSeedSaveData()); + } } // The bed can be built in the editor, but slots added through blueprints need their visuals as well @@ -146,9 +175,19 @@ void ASeedBedActor::BeginPlay() void ASeedBedActor::EndPlay(const EEndPlayReason::Type EndPlayReason) { - AEleriGameState* GameState = Cast(UGameplayStatics::GetGameState(this)); - ensure(GameState); - GameState->CacheSeedData(SeedBedIndex, PrepareSeedSaveData()); + // This is the moment the bed stops receiving broadcasts, so the resume math starts from the time of the day here + if (IsValid(TimeOfDayManager)) + { + TimeOfDayManager->OnDayPassed.RemoveDynamic(this, &ASeedBedActor::HandleDayPassed); + } + + CacheCurrentTimeOfDay(); + LastUpdateTime = LastKnownTimeOfDay; + + if (AEleriGameState* GameState = Cast(UGameplayStatics::GetGameState(this))) + { + GameState->CacheSeedData(SeedBedIndex, PrepareSeedSaveData()); + } Super::EndPlay(EndPlayReason); } @@ -158,6 +197,166 @@ void ASeedBedActor::Tick(float DeltaTime) Super::Tick(DeltaTime); } //------------------------------------------------------------------------------------------------ +// TIME +//------------------------------------------------------------------------------------------------ + +void ASeedBedActor::BindToTimeOfDay() +{ + TimeOfDayManager = Cast(UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass())); + if (!IsValid(TimeOfDayManager)) + { + return; + } + + // AddUniqueDynamic only ever leaves one binding of the bed on the delegate, even after a reload + TimeOfDayManager->OnDayPassed.AddUniqueDynamic(this, &ASeedBedActor::HandleDayPassed); +} + +void ASeedBedActor::CacheCurrentTimeOfDay() +{ + if (IsValid(TimeOfDayManager)) + { + LastKnownTimeOfDay = TimeOfDayManager->GetTimeOfDay(); + } +} + +void ASeedBedActor::HandleDayPassed() +{ + AdvanceSeedBedByDays(1); +} + +void ASeedBedActor::AdvanceSeedBedByDays(int32 Days) +{ + if (Days <= 0) + { + return; + } + + CacheCurrentTimeOfDay(); + const FTimeOfDayData Now = LastKnownTimeOfDay; + + // Plants that die are reported after the map has been walked, listeners are free to change the bed + TArray DiedSlots; + + for (TPair& SlotPair : SeedBedSeedSlotsMap) + { + const bool bWasAlive = SlotHasLivingPlant(SlotPair.Value); + ApplyDayPassedToSlot(SlotPair.Value, Now); + + if (bWasAlive && !SlotHasLivingPlant(SlotPair.Value)) + { + DiedSlots.Add(SlotPair.Key); + } + } + + LastUpdateTime = Now; + RefreshAllSlotVisuals(); + + for (const FIntVector2& SlotCoordinate : DiedSlots) + { + OnSeedBedSlotChanged.Broadcast(SlotCoordinate, false); + } +} + +void ASeedBedActor::ApplyDayPassedToSlot(FSeedBedSeedSlot& Slot, const FTimeOfDayData& Now) +{ + FSeedBedSeedSlotData& SlotData = Slot.SlotData; + + // The moisture of the soil only lasts for the day it was watered on + const bool bWasWatered = SlotData.bWateredToday; + SlotData.bWateredToday = false; + + if (!SlotHasLivingPlant(Slot)) + { + return; + } + + // A plant that was dry for too long never grows again, so the death check comes before the growth + const int32 DaysSinceWatering = SlotData.LastWateredTime.IsValidTime() + ? Now.ToAbsoluteDays() - SlotData.LastWateredTime.ToAbsoluteDays() + : 0; + + if (DaysSinceWatering >= FMath::Max(1, DaysWithoutWaterToDie)) + { + SlotData.bDied = true; + return; + } + + // Only a watered plant grows, and it never grows past its own grow time + if (bWasWatered) + { + SlotData.SeedAge = FMath::Min(SlotData.SeedAge + 1, FMath::Max(0, SlotData.GrowTime)); + } +} + +void ASeedBedActor::ApplyOfflineProgression() +{ + CacheCurrentTimeOfDay(); + + // Without a time of day actor there is nothing to compare against, the bed stays exactly as it was saved + if (!LastKnownTimeOfDay.IsValidTime()) + { + return; + } + + // A save from before the stamps existed cannot be measured, the countdown simply starts now + if (!LastUpdateTime.IsValidTime()) + { + LastUpdateTime = LastKnownTimeOfDay; + + for (TPair& SlotPair : SeedBedSeedSlotsMap) + { + if (SlotHasLivingPlant(SlotPair.Value) && !SlotPair.Value.SlotData.LastWateredTime.IsValidTime()) + { + SlotPair.Value.SlotData.LastWateredTime = LastKnownTimeOfDay; + } + } + + return; + } + + const int32 DaysPassed = LastKnownTimeOfDay.ToAbsoluteDays() - LastUpdateTime.ToAbsoluteDays(); + if (DaysPassed > 0) + { + // Growth is linear and nothing can be watered while the bed is gone, so a single pass is enough + AdvanceSeedBedByDays(DaysPassed); + } + else + { + LastUpdateTime = LastKnownTimeOfDay; + } +} + +void ASeedBedActor::WaterSeedInSlot(FIntVector2 SlotCoordinate) +{ + FSeedBedSeedSlot* Slot = SeedBedSeedSlotsMap.Find(SlotCoordinate); + if (!Slot || !SlotHasLivingPlant(*Slot)) + { + return; + } + + CacheCurrentTimeOfDay(); + + // Wetness is a per day state, the stamp is what the death check measures the days since + Slot->SlotData.bWateredToday = true; + Slot->SlotData.WateredTimes++; + Slot->SlotData.LastWateredTime = LastKnownTimeOfDay; + + RefreshSlotVisual(SlotCoordinate); +} + +void ASeedBedActor::WaterAllSeedSlots() +{ + // The slots are collected first, a listener of the refresh is free to change the map while we water + TArray SlotCoordinates; + SeedBedSeedSlotsMap.GetKeys(SlotCoordinates); + + for (const FIntVector2& SlotCoordinate : SlotCoordinates) + { + WaterSeedInSlot(SlotCoordinate); + } +} +//------------------------------------------------------------------------------------------------ // GRID GENERATION //------------------------------------------------------------------------------------------------ @@ -233,7 +432,10 @@ FVector ASeedBedActor::GetSlotRelativeLocation(FIntVector2 SlotCoordinate) const // The grid is centered on the actor, so the coordinates are shifted by half a cell if (const FSeedBedSeedSlot* Slot = SeedBedSeedSlotsMap.Find(SlotCoordinate)) { - return Slot->SeedBedDecal->GetRelativeLocation(); + if (IsValid(Slot->SeedBedDecal)) + { + return Slot->SeedBedDecal->GetRelativeLocation(); + } } return FVector::ZeroVector; @@ -243,6 +445,7 @@ FSeedBedSaveData ASeedBedActor::PrepareSeedSaveData() { FSeedBedSaveData SaveData; SaveData.Index = SeedBedIndex; + SaveData.LastUpdateTime = LastUpdateTime; for (const auto& Slot : SeedBedSeedSlotsMap) { SaveData.SlotData.Add(Slot.Key, Slot.Value.SlotData); @@ -257,6 +460,7 @@ void ASeedBedActor::PropagateSeedSaveData(const FSeedBedSaveData& SeedSaveData) ensure(ItemDatabase); SeedBedIndex = SeedSaveData.Index; + LastUpdateTime = SeedSaveData.LastUpdateTime; for (const auto& SavedSlot : SeedSaveData.SlotData) { if (FSeedBedSeedSlot* SeedSlot = SeedBedSeedSlotsMap.Find(SavedSlot.Key)) @@ -266,6 +470,9 @@ void ASeedBedActor::PropagateSeedSaveData(const FSeedBedSaveData& SeedSaveData) SeedSlot->SlotData.ItemDataAsset = ItemDatabase->GetDataAssetFromId(SeedSlot->SlotData.ItemAssetID); } } + + // Time kept running while the bed was not in the world, every plant is brought up to date in one pass + ApplyOfflineProgression(); RefreshAllSlotVisuals(); } @@ -293,6 +500,17 @@ FSeedBedSeedSlot& ASeedBedActor::CreateSlot(FIntVector2 SlotCoordinate, bool bRe Offset.Y + Y + SeedBedDecalSize.Y, 0.f)); + // The plant mesh of a slot lives as long as the slot does, the visuals only ever show or hide it + if (!IsValid(Slot.PlantStaticMesh)) + { + Slot.PlantStaticMesh = CreateSlotMeshComponent(this, GetRootComponent(), GetSlotComponentName(SlotCoordinate, TEXT("Plant"))); + if (IsValid(Slot.PlantStaticMesh)) + { + Slot.PlantStaticMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision); + Slot.PlantStaticMesh->SetVisibility(false); + } + } + ApplySlotVisuals(SlotCoordinate, Slot); return Slot; @@ -309,37 +527,52 @@ void ASeedBedActor::ApplySlotVisuals(FIntVector2 SlotCoordinate, FSeedBedSeedSlo if (IsValid(Slot.SeedBedDecal)) { Slot.SeedBedDecal->SetVisibility(Slot.SlotData.ItemAssetID.IsValid()); - Slot.SeedBedDynamicMat->SetScalarParameterValue(TEXT("Wetness"), Slot.SlotData.bWateredToday); + + if (IsValid(Slot.SeedBedDynamicMat)) + { + Slot.SeedBedDynamicMat->SetScalarParameterValue(TEXT("Wetness"), Slot.SlotData.bWateredToday ? 1.f : 0.f); + } } // PLANT ---------------------------------------------------------------------------------------- - const bool bHasPlant = SlotHasPlant(Slot) && !Slot.SlotData.bDied; + const bool bHasPlant = SlotHasLivingPlant(Slot); - if (IsValid(Slot.PlantStaticMesh)) + if (!IsValid(Slot.PlantStaticMesh)) { - UStaticMesh* PlantMesh = nullptr; - FVector PlantOffset = FVector::ZeroVector; - FVector PlantScale = FVector::OneVector; - - if (bHasPlant) - { - const int32 StageIndex = ResolveGrowthStageIndex(Slot); - const UItemDataAsset* SeedData = Slot.SlotData.ItemDataAsset; - if (IsValid(SeedData) && SeedData->SeedStages.IsValidIndex(StageIndex)) - { - const FSeedStage& Stage = SeedData->SeedStages[StageIndex]; - PlantMesh = ResolveSoftObject(Stage.Mesh); - PlantOffset = Stage.MeshOffset; - PlantScale = SanitizeScale(Stage.MeshScale); - } - - // A seed without stage meshes still has to show up, the fallback keeps the plant visible - if (!PlantMesh) - { - PlantMesh = ResolveSoftObject(DefaultPlantMesh); - } - } + return; } + + if (!bHasPlant) + { + // A plant that died or was harvested keeps its mesh, the slot is only emptied when it is cleared for real + Slot.PlantStaticMesh->SetVisibility(false); + return; + } + + UStaticMesh* PlantMesh = nullptr; + FVector PlantOffset = FVector::ZeroVector; + FVector PlantScale = FVector::OneVector; + + const int32 StageIndex = ResolveGrowthStageIndex(Slot); + const UItemDataAsset* SeedData = Slot.SlotData.ItemDataAsset; + if (IsValid(SeedData) && SeedData->SeedStages.IsValidIndex(StageIndex)) + { + const FSeedStage& Stage = SeedData->SeedStages[StageIndex]; + PlantMesh = ResolveSoftObject(Stage.Mesh); + PlantOffset = Stage.MeshOffset; + PlantScale = SanitizeScale(Stage.MeshScale); + } + + // A seed without stage meshes still has to show up, the fallback keeps the plant visible + if (!PlantMesh) + { + PlantMesh = ResolveSoftObject(DefaultPlantMesh); + } + + Slot.PlantStaticMesh->SetStaticMesh(PlantMesh); + Slot.PlantStaticMesh->SetRelativeLocation(SlotLocation + PlantOffset); + Slot.PlantStaticMesh->SetRelativeScale3D(PlantScale); + Slot.PlantStaticMesh->SetVisibility(PlantMesh != nullptr); } void ASeedBedActor::RefreshSlotVisual(FIntVector2 SlotCoordinate) { @@ -351,7 +584,7 @@ void ASeedBedActor::RefreshSlotVisual(FIntVector2 SlotCoordinate) ApplySlotVisuals(SlotCoordinate, *Slot); - OnSeedBedSlotChanged.Broadcast(SlotCoordinate, SlotHasPlant(*Slot)); + OnSeedBedSlotChanged.Broadcast(SlotCoordinate, SlotHasLivingPlant(*Slot)); } void ASeedBedActor::RefreshAllSlotVisuals() @@ -397,7 +630,8 @@ bool ASeedBedActor::IsSlotFree(FIntVector2 SlotCoordinate) const { const FSeedBedSeedSlot* Slot = SeedBedSeedSlotsMap.Find(SlotCoordinate); - return Slot != nullptr && !SlotHasPlant(*Slot); + // A dead plant still holds its mesh, but the slot can be planted again + return Slot != nullptr && !SlotHasLivingPlant(*Slot); } int32 ASeedBedActor::GetFreeSlotCount() const @@ -406,7 +640,7 @@ int32 ASeedBedActor::GetFreeSlotCount() const for (const TPair& SlotPair : SeedBedSeedSlotsMap) { - if (!SlotHasPlant(SlotPair.Value)) + if (!SlotHasLivingPlant(SlotPair.Value)) { ++FreeSlotCount; } @@ -421,7 +655,7 @@ bool ASeedBedActor::FindClosestFreeSlot(const FVector& WorldLocation, FIntVector for (const TPair& SlotPair : SeedBedSeedSlotsMap) { - if (SlotHasPlant(SlotPair.Value)) + if (!SlotHasLivingPlant(SlotPair.Value)) { continue; } @@ -498,6 +732,7 @@ bool ASeedBedActor::PlantSeedInSlot(FIntVector2 SlotCoordinate, UItemDataAsset* } // A fresh plant starts at day zero, everything that is stored about it is the state of the new seed + CacheCurrentTimeOfDay(); Slot->SlotData.ItemDataAsset = SeedData; Slot->SlotData.ItemAssetID = SeedData->GetPrimaryAssetId(); Slot->SlotData.GrowTime = FMath::Max(0, SeedData->GrowTimeSelf); @@ -507,8 +742,14 @@ bool ASeedBedActor::PlantSeedInSlot(FIntVector2 SlotCoordinate, UItemDataAsset* Slot->SlotData.bWateredToday = false; Slot->SlotData.bDied = false; Slot->SlotData.bHighQuality = bHighQuality; - - Slot->SeedBedDynamicMat->SetScalarParameterValue(TEXT("Wetness"), 0.f); + + // A plant that is never watered has to dry out from the day it was planted on + Slot->SlotData.LastWateredTime = LastKnownTimeOfDay; + + if (IsValid(Slot->SeedBedDynamicMat)) + { + Slot->SeedBedDynamicMat->SetScalarParameterValue(TEXT("Wetness"), 0.f); + } RefreshSlotVisual(SlotCoordinate); @@ -532,6 +773,7 @@ void ASeedBedActor::ClearSeedFromSlot(FIntVector2 SlotCoordinate, bool bRemovePl Slot->SlotData.bWateredToday = false; Slot->SlotData.bDied = false; Slot->SlotData.bHighQuality = false; + Slot->SlotData.LastWateredTime = FTimeOfDayData(); if (bRemovePlantMesh) { diff --git a/Source/ProjectEleri/Items/SeedBedActor.h b/Source/ProjectEleri/Items/SeedBedActor.h index 8b6fe8fc..99101f2c 100644 --- a/Source/ProjectEleri/Items/SeedBedActor.h +++ b/Source/ProjectEleri/Items/SeedBedActor.h @@ -4,10 +4,12 @@ #include "CoreMinimal.h" #include "GameFramework/Actor.h" +#include "ProjectEleri/System/TimeOfDayStruct.h" #include "SeedBedActor.generated.h" class ATimeOfDayManager; class UDecalComponent; +class UMaterialInstanceDynamic; class UMaterialInterface; class UStaticMesh; class UStaticMeshComponent; @@ -47,6 +49,10 @@ struct FSeedBedSeedSlotData UPROPERTY(EditAnywhere, BlueprintReadWrite) bool bHighQuality = false; + + /** In game time of the last watering, the death check measures the days that passed since then */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FTimeOfDayData LastWateredTime; }; USTRUCT(BlueprintType) @@ -77,6 +83,10 @@ struct FSeedBedSaveData UPROPERTY(VisibleAnywhere, BlueprintReadOnly) TMap SlotData; + + /** In game time the bed last applied growth to its slots, the resume math starts from this stamp */ + UPROPERTY(EditAnywhere, BlueprintReadWrite) + FTimeOfDayData LastUpdateTime; }; UCLASS() @@ -168,6 +178,14 @@ public: UFUNCTION(BlueprintCallable, Category = "Seed Bed") void ClearSeedFromSlot(FIntVector2 SlotCoordinate, bool bRemovePlantMesh = true); + /** Waters a single slot: refills the soil and restarts the countdown the death check measures */ + UFUNCTION(BlueprintCallable, Category = "Seed Bed") + void WaterSeedInSlot(FIntVector2 SlotCoordinate); + + /** Waters every planted slot of the bed */ + UFUNCTION(BlueprintCallable, Category = "Seed Bed") + void WaterAllSeedSlots(); + /** Finds the free slot that is closest to the given world location */ UFUNCTION(BlueprintCallable, Category = "Seed Bed") bool FindClosestFreeSlot(const FVector& WorldLocation, FIntVector2& OutSlotCoordinate) const; @@ -185,6 +203,18 @@ public: UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Seed Bed") bool HasFreeSlot() const { return GetFreeSlotCount() > 0; } + //------------------------------------------------------------------------------------------------ + // TIME + //------------------------------------------------------------------------------------------------ + + /** The in game time the bed last applied growth for, the stamp the resume math continues from */ + UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Seed Bed|Time") + FTimeOfDayData GetLastUpdateTime() const { return LastUpdateTime; } + + /** Applies the given amount of in game days to every plant of the bed, used by the day tick and the resume catch up */ + UFUNCTION(BlueprintCallable, Category = "Seed Bed|Time") + void AdvanceSeedBedByDays(int32 Days); + //------------------------------------------------------------------------------------------------ // VISUALS //------------------------------------------------------------------------------------------------ @@ -228,6 +258,22 @@ protected: FVector GetSlotRelativeLocation(FIntVector2 SlotCoordinate) const; + /** Subscribes to the time of day actor, the bed ages its plants on every day that passes */ + void BindToTimeOfDay(); + + /** Applies one in game day of growth, moisture handling and death checks to a single slot */ + void ApplyDayPassedToSlot(FSeedBedSeedSlot& Slot, const FTimeOfDayData& Now); + + /** Catches the plants up on the in game time that passed while the bed was not in the world */ + void ApplyOfflineProgression(); + + /** Stores the current in game time, the manager may already be gone when the bed is torn down */ + void CacheCurrentTimeOfDay(); + + /** Day tick of the time of day actor */ + UFUNCTION() + void HandleDayPassed(); + /** Whether the highlight decals of the bed are currently shown */ bool bPlantingHighlightActive = false; @@ -238,7 +284,19 @@ protected: /** Decal used to highlight this slot when the player is looking for a place to plant a seed */ UPROPERTY(VisibleAnywhere, BlueprintReadOnly) UDecalComponent* HighlightDecal = nullptr; - + + /** Days a plant survives without being watered before it dies */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Seed Bed|Time") + int32 DaysWithoutWaterToDie = 2; + + /** In game time the bed last applied growth for, written on every day and again when the bed leaves the world */ + UPROPERTY(Transient) + FTimeOfDayData LastUpdateTime; + + /** Copy of the last time the manager reported, used when the bed is torn down after the manager is gone */ + UPROPERTY(Transient) + FTimeOfDayData LastKnownTimeOfDay; + UPROPERTY() ATimeOfDayManager* TimeOfDayManager = nullptr; diff --git a/Source/ProjectEleri/Private/EleriGameState.cpp b/Source/ProjectEleri/Private/EleriGameState.cpp index b9eb38ea..5c3de480 100644 --- a/Source/ProjectEleri/Private/EleriGameState.cpp +++ b/Source/ProjectEleri/Private/EleriGameState.cpp @@ -69,6 +69,41 @@ void AEleriGameState::PropagateAllSeedData() } } +void AEleriGameState::WaterAllSeedBeds() +{ + const ATimeOfDayManager* TimeOfDay = Cast( + UGameplayStatics::GetActorOfClass(GetWorld(), ATimeOfDayManager::StaticClass())); + if (!TimeOfDay) return; + + TArray OutActors; + UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASeedBedActor::StaticClass(), OutActors); + + TArray LoadedSeedBeds; + LoadedSeedBeds.Reserve(OutActors.Num()); + + // Only the cached data also covers the beds that are streamed out, so it is the source of truth. + // Loaded beds know more than the cache does, so they are cached first, exactly like the save does it. + for (AActor* Actor : OutActors) + { + ASeedBedActor* SeedBedActor = Cast(Actor); + if (!SeedBedActor) continue; + + LoadedSeedBeds.Add(SeedBedActor); + CacheSeedData(SeedBedActor->SeedBedIndex, SeedBedActor->PrepareSeedSaveData()); + } + + SaveData.WaterAllPlantedSeeds(TimeOfDay->GetTimeOfDay()); + + // The loaded beds adopt the watered state right away, the others pick it up when they are streamed in + for (ASeedBedActor* SeedBedActor : LoadedSeedBeds) + { + if (const FSeedBedSaveData* SeedBedData = SaveData.SeedBedData.Find(SeedBedActor->SeedBedIndex)) + { + SeedBedActor->PropagateSeedSaveData(*SeedBedData); + } + } +} + void AEleriGameState::PropagateSaveData(UEleriSaveGame* InSaveData) { check(InSaveData); @@ -248,6 +283,7 @@ bool AEleriGameState::GetSeedData(int32 Index, FSeedBedSaveData& OutData) if (const FSeedBedSaveData* FoundData = SaveData.SeedBedData.Find(Index)) { OutData = *FoundData; + return true; } return false; diff --git a/Source/ProjectEleri/Private/EleriSaveGame.cpp b/Source/ProjectEleri/Private/EleriSaveGame.cpp index e84a53e0..b78dd4d5 100644 --- a/Source/ProjectEleri/Private/EleriSaveGame.cpp +++ b/Source/ProjectEleri/Private/EleriSaveGame.cpp @@ -53,6 +53,27 @@ void FSaveData::SetPlantedSeedData(int32 Index, const FSeedBedSaveData& SeedSave SeedBedData.Add(Index, SeedSaveData); } +void FSaveData::WaterAllPlantedSeeds(const FTimeOfDayData& WateredTime) +{ + for (TPair& SeedBedPair : SeedBedData) + { + for (TPair& SlotPair : SeedBedPair.Value.SlotData) + { + FSeedBedSeedSlotData& SlotData = SlotPair.Value; + + // Rain refills the soil of living plants, it neither plants nor revives anything + if (!SlotData.ItemAssetID.IsValid() || SlotData.bDied) + { + continue; + } + + SlotData.bWateredToday = true; + SlotData.WateredTimes++; + SlotData.LastWateredTime = WateredTime; + } + } +} + void FSaveData::SetPlacedObjectsData(TArray InPlacedActors) { PlacedActors = InPlacedActors; diff --git a/Source/ProjectEleri/Private/TimeOfDayManager.cpp b/Source/ProjectEleri/Private/TimeOfDayManager.cpp index 5bf8a2e4..8bae24f7 100644 --- a/Source/ProjectEleri/Private/TimeOfDayManager.cpp +++ b/Source/ProjectEleri/Private/TimeOfDayManager.cpp @@ -17,6 +17,7 @@ #include "Kismet/KismetMaterialLibrary.h" #include "Materials/MaterialParameterCollection.h" #include "ProjectEleri/Data/WeatherPresetDataAsset.h" +#include "EleriGameState.h" #include "ProjectEleri/GameEventSystem/GameEventSubsystem.h" #include "ProjectEleri/System/MainBlueprintFunctionLibrary.h" @@ -120,6 +121,15 @@ void ATimeOfDayManager::Tick(float DeltaTime) CurrentWeaterFadeAlpha = 1.f; QueuedWeatherPreset = GetWeatherDistributionValue(); K2_WeatherFadeStart(QueuedWeatherPreset->bIsRain); + + if (QueuedWeatherPreset->bIsRain) + { + // Rain refills the soil of every plant, the game state also reaches the beds that are streamed out + if (AEleriGameState* GameState = AEleriGameState::Get(this)) + { + GameState->WaterAllSeedBeds(); + } + } } else { diff --git a/Source/ProjectEleri/Public/EleriGameState.h b/Source/ProjectEleri/Public/EleriGameState.h index 943434ab..88d6fb2d 100644 --- a/Source/ProjectEleri/Public/EleriGameState.h +++ b/Source/ProjectEleri/Public/EleriGameState.h @@ -43,6 +43,10 @@ public: void CacheSeedData(int32 Index, const FSeedBedSaveData& Data); bool GetSeedData(int32 Index, FSeedBedSaveData& OutData); + /** Waters every planted seed of every seed bed, loaded or not. Called when it starts raining. */ + UFUNCTION(BlueprintCallable, Category = "Game State") + void WaterAllSeedBeds(); + UPROPERTY() FOnPropagateSaveDataComplete OnPropagateSaveDataComplete; diff --git a/Source/ProjectEleri/Public/EleriSaveGame.h b/Source/ProjectEleri/Public/EleriSaveGame.h index 484eef79..60bff798 100644 --- a/Source/ProjectEleri/Public/EleriSaveGame.h +++ b/Source/ProjectEleri/Public/EleriSaveGame.h @@ -80,6 +80,8 @@ struct FSaveData { void SetBookProgressionData(TArray _BookProgression); void SetInventoryDataForActor(AActor* Actor, UInventoryComponent* Inventory); void SetPlantedSeedData(int32 Index, const FSeedBedSaveData& SeedSaveData); + /** Refills the soil of every planted seed of the cached seed beds, used by rain */ + void WaterAllPlantedSeeds(const FTimeOfDayData& WateredTime); void SetPlacedObjectsData(TArray InPlacedActors); void CollectSaveableActorsData(const UWorld* World); void SetGameEventData(const UWorld* World); diff --git a/Source/ProjectEleri/System/TimeOfDayStruct.h b/Source/ProjectEleri/System/TimeOfDayStruct.h index 9bf2c9ba..cb8317e7 100644 --- a/Source/ProjectEleri/System/TimeOfDayStruct.h +++ b/Source/ProjectEleri/System/TimeOfDayStruct.h @@ -31,4 +31,20 @@ public: bool operator==(const FTimeOfDayData& Other) const { return Year == Other.Year && Month == Other.Month && Day == Other.Day && Hour == Other.Hour && Minute == Other.Minute; } + + /** Sums up years, months and days into a single monotonic day counter so two stamps can be compared. + * The formula mirrors ATimeOfDayManager::GetAllDaysPassed(), keep both in sync. */ + int32 ToAbsoluteDays() const { + return ((Year - 1) * 4 * 28) + ((Month - 1) * 28) + Day; + } + + /** The same counter in minutes, for the places that need a finer resolution than whole days */ + int32 ToAbsoluteMinutes() const { + return (ToAbsoluteDays() * 24 * 60) + (Hour * 60) + Minute; + } + + /** A stamp that was never written, for example a save from before the stamp existed, is all zeroes */ + bool IsValidTime() const { + return Year > 0 || Month > 0 || Day > 0; + } };