From dca9d01f68113542fba34a35bf9b72050484a3d4 Mon Sep 17 00:00:00 2001 From: nathan Date: Tue, 19 May 2026 12:14:16 -0700 Subject: [PATCH] Add vegetation ignition risk checks --- AGRARIAN_DEVELOPMENT_ROADMAP.md | 2 +- Docs/TechnicalDesignDocument.md | 8 ++ Scripts/verify_vegetation_ignition_checks.py | 79 +++++++++++ Source/AgrarianGame/AgrarianCampfire.cpp | 132 +++++++++++++++++++ Source/AgrarianGame/AgrarianCampfire.h | 24 ++++ Source/AgrarianGame/AgrarianFoliagePatch.cpp | 50 +++++++ Source/AgrarianGame/AgrarianFoliagePatch.h | 17 +++ 7 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 Scripts/verify_vegetation_ignition_checks.py diff --git a/AGRARIAN_DEVELOPMENT_ROADMAP.md b/AGRARIAN_DEVELOPMENT_ROADMAP.md index 5e8fb14..6a43bbe 100644 --- a/AGRARIAN_DEVELOPMENT_ROADMAP.md +++ b/AGRARIAN_DEVELOPMENT_ROADMAP.md @@ -838,7 +838,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe - [x] Add gathering sounds. Added spatialized resource-node gathering audio hooks with assignable normal/depleted gathering cues and a server-authoritative multicast trigger after successful harvests, keeping multiplayer clients aligned while remaining silent until audio assets are assigned. - [x] Add fire sounds. Added campfire loop, ignition, and extinguish audio hooks with spatialized components, replicated lit-state loop control, and server-triggered multicast event cues so fire audio follows the authoritative campfire state once assets are assigned. - [x] Add unattended and poorly maintained fire risk for campfires and other open-flame sources. Added server-side campfire risk state for lit duration, seconds since maintenance, cleared area, containment, high fuel, wet weather mitigation, and a replicated `FireRiskScore` that later ignition/spread systems can consume. -- [ ] Add grass and forest ignition checks from irresponsible fire placement, wind/weather, dry fuel, nearby vegetation, and burn duration. +- [x] Add grass and forest ignition checks from irresponsible fire placement, wind/weather, dry fuel, nearby vegetation, and burn duration. Added foliage fuel counting, campfire vegetation ignition risk scores, grass/brush and forest ignition flags, and weather/wind/burn-duration modifiers so unsafe fire placement near dry fuel can now become a server-authoritative ignition risk. - [ ] Add shelter/structure ignition risk when fires are placed too close to primitive shelters, wood piles, flammable crafting stations, or settlement objects. - [ ] Add server-authoritative fire spread rules for grass, brush, trees, shelters, and other burnable actors, including fuel, distance, wind, weather, and suppression hooks. - [ ] Add fire maintenance gameplay so watched, cleared, contained, or extinguished fires are safe, while neglected fires can become dangerous. diff --git a/Docs/TechnicalDesignDocument.md b/Docs/TechnicalDesignDocument.md index fbb5699..3b27789 100644 --- a/Docs/TechnicalDesignDocument.md +++ b/Docs/TechnicalDesignDocument.md @@ -345,6 +345,14 @@ when excessive fuel is burning, and is reduced by maintenance, cleared area, containment, and wet weather. This first risk layer does not yet ignite nearby vegetation or structures; later 0.1.P items consume the replicated risk score. +Grass and forest ignition checks consume that risk score. Campfires query +`AAgrarianFoliagePatch` for nearby grass, shrub, and tree instance counts inside +`VegetationIgnitionCheckRadius`. Unsafe placement near dry fuel accumulates +separate replicated grass/brush and forest ignition risk, adjusted by burn +duration, wind speed, current weather, cleared-area maintenance, and the +campfire's current risk ratio. Reaching the threshold marks the relevant +ignition flag, while later spread rules decide how active fires propagate. + Campfires expose native extinguish logic through `AAgrarianCampfire::Extinguish`. Extinguishing clears remaining fuel, turns off replicated lit state, and reuses the same visual update path as natural fuel depletion. diff --git a/Scripts/verify_vegetation_ignition_checks.py b/Scripts/verify_vegetation_ignition_checks.py new file mode 100644 index 0000000..48b9aef --- /dev/null +++ b/Scripts/verify_vegetation_ignition_checks.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Verify grass/forest ignition checks use fire risk, foliage fuel, and weather.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +FOLIAGE_H = ROOT / "Source" / "AgrarianGame" / "AgrarianFoliagePatch.h" +FOLIAGE_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianFoliagePatch.cpp" +FIRE_H = ROOT / "Source" / "AgrarianGame" / "AgrarianCampfire.h" +FIRE_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianCampfire.cpp" +TDD = ROOT / "Docs" / "TechnicalDesignDocument.md" +ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md" + +REQUIRED = { + FOLIAGE_H: [ + "GetFuelCountsNearLocation", + "GetDryVegetationFuelScoreNearLocation", + "CountInstancesNearLocation", + ], + FOLIAGE_CPP: [ + "AAgrarianFoliagePatch::GetFuelCountsNearLocation", + "AAgrarianFoliagePatch::GetDryVegetationFuelScoreNearLocation", + "AAgrarianFoliagePatch::CountInstancesNearLocation", + "GetInstanceTransform", + "FVector::DistSquared2D", + ], + FIRE_H: [ + "GrassIgnitionRiskScore", + "ForestIgnitionRiskScore", + "bGrassOrBrushIgnited", + "bForestFuelIgnited", + "VegetationIgnitionCheckRadius", + "VegetationIgnitionFuelScoreThreshold", + "UpdateVegetationIgnitionRisk", + "GetVegetationFuelScoreNearFire", + "GetVegetationIgnitionWeatherMultiplier", + ], + FIRE_CPP: [ + "#include \"AgrarianFoliagePatch.h\"", + "DOREPLIFETIME(AAgrarianCampfire, GrassIgnitionRiskScore)", + "DOREPLIFETIME(AAgrarianCampfire, ForestIgnitionRiskScore)", + "UpdateVegetationIgnitionRisk(DeltaSeconds);", + "UGameplayStatics::GetAllActorsOfClass(this, AAgrarianFoliagePatch::StaticClass()", + "GameState->ActiveWeatherInputs.WindSpeedKmh", + "EAgrarianWeatherType::Rain", + "EAgrarianWeatherType::Storm", + "EAgrarianWeatherType::ColdWind", + "LitDurationSeconds", + "GetFireRiskRatio()", + "bGrassOrBrushIgnited = GrassIgnitionRiskScore >= 100.0f", + "bForestFuelIgnited = ForestIgnitionRiskScore >= 100.0f", + ], + TDD: [ + "Grass and forest ignition checks consume that risk score", + "`AAgrarianFoliagePatch`", + "wind speed", + "burn\nduration", + ], + ROADMAP: [ + "[x] Add grass and forest ignition checks", + ], +} + + +def main() -> None: + missing = [] + for path, snippets in REQUIRED.items(): + text = path.read_text(encoding="utf-8") + for snippet in snippets: + if snippet not in text: + missing.append(f"{path.relative_to(ROOT)} missing {snippet!r}") + if missing: + raise SystemExit("FAILED: " + "; ".join(missing)) + print("OK: vegetation ignition checks are wired to fire risk, foliage, and weather.") + + +if __name__ == "__main__": + main() diff --git a/Source/AgrarianGame/AgrarianCampfire.cpp b/Source/AgrarianGame/AgrarianCampfire.cpp index 122796e..ad1ac2e 100644 --- a/Source/AgrarianGame/AgrarianCampfire.cpp +++ b/Source/AgrarianGame/AgrarianCampfire.cpp @@ -1,6 +1,7 @@ // Copyright Pacificao. All Rights Reserved. #include "AgrarianCampfire.h" +#include "AgrarianFoliagePatch.h" #include "AgrarianGameCharacter.h" #include "AgrarianGameState.h" #include "AgrarianInventoryComponent.h" @@ -141,6 +142,7 @@ void AAgrarianCampfire::Tick(float DeltaSeconds) } UpdateFireRisk(DeltaSeconds); + UpdateVegetationIgnitionRisk(DeltaSeconds); WarmNearbyCharacters(DeltaSeconds); } } @@ -158,6 +160,10 @@ void AAgrarianCampfire::GetLifetimeReplicatedProps(TArray& Ou DOREPLIFETIME(AAgrarianCampfire, SecondsSinceMaintenance); DOREPLIFETIME(AAgrarianCampfire, bFireAreaCleared); DOREPLIFETIME(AAgrarianCampfire, bFireContained); + DOREPLIFETIME(AAgrarianCampfire, GrassIgnitionRiskScore); + DOREPLIFETIME(AAgrarianCampfire, ForestIgnitionRiskScore); + DOREPLIFETIME(AAgrarianCampfire, bGrassOrBrushIgnited); + DOREPLIFETIME(AAgrarianCampfire, bForestFuelIgnited); } FText AAgrarianCampfire::GetInteractionText_Implementation(const AAgrarianGameCharacter* Interactor) const @@ -201,6 +207,10 @@ void AAgrarianCampfire::CapturePersistentState_Implementation(UAgrarianPersisten PersistentComponent->NumberState.Add(TEXT("seconds_since_maintenance"), SecondsSinceMaintenance); PersistentComponent->NumberState.Add(TEXT("fire_area_cleared"), bFireAreaCleared ? 1.0f : 0.0f); PersistentComponent->NumberState.Add(TEXT("fire_contained"), bFireContained ? 1.0f : 0.0f); + PersistentComponent->NumberState.Add(TEXT("grass_ignition_risk_score"), GrassIgnitionRiskScore); + PersistentComponent->NumberState.Add(TEXT("forest_ignition_risk_score"), ForestIgnitionRiskScore); + PersistentComponent->NumberState.Add(TEXT("grass_or_brush_ignited"), bGrassOrBrushIgnited ? 1.0f : 0.0f); + PersistentComponent->NumberState.Add(TEXT("forest_fuel_ignited"), bForestFuelIgnited ? 1.0f : 0.0f); } void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentActorComponent* PersistentComponent) @@ -220,6 +230,10 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA const float* SavedSecondsSinceMaintenance = PersistentComponent->NumberState.Find(TEXT("seconds_since_maintenance")); const float* SavedAreaCleared = PersistentComponent->NumberState.Find(TEXT("fire_area_cleared")); const float* SavedContained = PersistentComponent->NumberState.Find(TEXT("fire_contained")); + const float* SavedGrassIgnitionRisk = PersistentComponent->NumberState.Find(TEXT("grass_ignition_risk_score")); + const float* SavedForestIgnitionRisk = PersistentComponent->NumberState.Find(TEXT("forest_ignition_risk_score")); + const float* SavedGrassIgnited = PersistentComponent->NumberState.Find(TEXT("grass_or_brush_ignited")); + const float* SavedForestIgnited = PersistentComponent->NumberState.Find(TEXT("forest_fuel_ignited")); if (SavedFuelSeconds) { @@ -266,6 +280,26 @@ void AAgrarianCampfire::ApplyPersistentState_Implementation(UAgrarianPersistentA bFireContained = *SavedContained > 0.5f; } + if (SavedGrassIgnitionRisk) + { + GrassIgnitionRiskScore = FMath::Clamp(*SavedGrassIgnitionRisk, 0.0f, 100.0f); + } + + if (SavedForestIgnitionRisk) + { + ForestIgnitionRiskScore = FMath::Clamp(*SavedForestIgnitionRisk, 0.0f, 100.0f); + } + + if (SavedGrassIgnited) + { + bGrassOrBrushIgnited = *SavedGrassIgnited > 0.5f; + } + + if (SavedForestIgnited) + { + bForestFuelIgnited = *SavedForestIgnited > 0.5f; + } + SetLit(SavedLit && *SavedLit > 0.5f && FuelSeconds > 0.0f); } @@ -291,6 +325,8 @@ void AAgrarianCampfire::Extinguish() { FuelSeconds = 0.0f; FireRiskScore = 0.0f; + GrassIgnitionRiskScore = 0.0f; + ForestIgnitionRiskScore = 0.0f; LitDurationSeconds = 0.0f; SecondsSinceMaintenance = 0.0f; SetLit(false); @@ -517,3 +553,99 @@ float AAgrarianCampfire::GetFireRiskGrowthPerSecond() const return FMath::Max(0.0f, RiskGrowth); } + +void AAgrarianCampfire::UpdateVegetationIgnitionRisk(float DeltaSeconds) +{ + if (!HasAuthority() || !bLit || bFireAreaCleared) + { + return; + } + + float GrassFuelScore = 0.0f; + float ForestFuelScore = 0.0f; + const float TotalFuelScore = GetVegetationFuelScoreNearFire(GrassFuelScore, ForestFuelScore); + if (TotalFuelScore < VegetationIgnitionFuelScoreThreshold) + { + return; + } + + const float BurnDurationMultiplier = FMath::GetMappedRangeValueClamped( + FVector2D(30.0f, 300.0f), + FVector2D(0.35f, 1.4f), + LitDurationSeconds); + const float RiskMultiplier = GetVegetationIgnitionWeatherMultiplier() * BurnDurationMultiplier * FMath::Max(0.0f, GetFireRiskRatio()); + const float BaseRisk = VegetationIgnitionRiskPerSecond * DeltaSeconds * RiskMultiplier; + + if (GrassFuelScore > 0.0f && !bGrassOrBrushIgnited) + { + GrassIgnitionRiskScore = FMath::Clamp(GrassIgnitionRiskScore + (BaseRisk * GrassFuelScore), 0.0f, 100.0f); + bGrassOrBrushIgnited = GrassIgnitionRiskScore >= 100.0f; + } + + if (ForestFuelScore > 0.0f && !bForestFuelIgnited) + { + ForestIgnitionRiskScore = FMath::Clamp(ForestIgnitionRiskScore + (BaseRisk * ForestFuelScore * 0.6f), 0.0f, 100.0f); + bForestFuelIgnited = ForestIgnitionRiskScore >= 100.0f; + } +} + +float AAgrarianCampfire::GetVegetationFuelScoreNearFire(float& OutGrassFuelScore, float& OutForestFuelScore) const +{ + OutGrassFuelScore = 0.0f; + OutForestFuelScore = 0.0f; + + TArray FoliageActors; + UGameplayStatics::GetAllActorsOfClass(this, AAgrarianFoliagePatch::StaticClass(), FoliageActors); + for (AActor* Actor : FoliageActors) + { + const AAgrarianFoliagePatch* FoliagePatch = Cast(Actor); + if (!FoliagePatch) + { + continue; + } + + int32 GrassCount = 0; + int32 ShrubCount = 0; + int32 TreeCount = 0; + FoliagePatch->GetFuelCountsNearLocation(GetActorLocation(), VegetationIgnitionCheckRadius, GrassCount, ShrubCount, TreeCount); + OutGrassFuelScore += static_cast(GrassCount) + (static_cast(ShrubCount) * 1.5f); + OutForestFuelScore += static_cast(TreeCount) * 3.0f; + } + + return OutGrassFuelScore + OutForestFuelScore; +} + +float AAgrarianCampfire::GetVegetationIgnitionWeatherMultiplier() const +{ + float Multiplier = 1.0f; + if (const UWorld* World = GetWorld()) + { + if (const AAgrarianGameState* GameState = World->GetGameState()) + { + if (GameState->ActiveWeatherInputs.bHasProviderData) + { + Multiplier *= FMath::GetMappedRangeValueClamped( + FVector2D(0.0f, 55.0f), + FVector2D(0.85f, 1.65f), + GameState->ActiveWeatherInputs.WindSpeedKmh); + } + + switch (GameState->Weather) + { + case EAgrarianWeatherType::Rain: + Multiplier *= 0.2f; + break; + case EAgrarianWeatherType::Storm: + Multiplier *= 0.1f; + break; + case EAgrarianWeatherType::ColdWind: + Multiplier *= 1.25f; + break; + default: + break; + } + } + } + + return FMath::Max(0.0f, Multiplier); +} diff --git a/Source/AgrarianGame/AgrarianCampfire.h b/Source/AgrarianGame/AgrarianCampfire.h index a502d28..05a4a3c 100644 --- a/Source/AgrarianGame/AgrarianCampfire.h +++ b/Source/AgrarianGame/AgrarianCampfire.h @@ -114,6 +114,27 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Risk", meta = (ClampMin = "0", ClampMax = "1")) float ContainedFireRiskMultiplier = 0.35f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Vegetation", meta = (ClampMin = "0", ClampMax = "100")) + float GrassIgnitionRiskScore = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Vegetation", meta = (ClampMin = "0", ClampMax = "100")) + float ForestIgnitionRiskScore = 0.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Vegetation") + bool bGrassOrBrushIgnited = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Replicated, Category = "Agrarian|Fire|Vegetation") + bool bForestFuelIgnited = false; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Vegetation", meta = (ClampMin = "0")) + float VegetationIgnitionCheckRadius = 700.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Vegetation", meta = (ClampMin = "0")) + float VegetationIgnitionFuelScoreThreshold = 8.0f; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Vegetation", meta = (ClampMin = "0")) + float VegetationIgnitionRiskPerSecond = 0.035f; + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Agrarian|Fire|Weather", meta = (ClampMin = "1")) float RainFuelDrainMultiplier = 1.5f; @@ -178,4 +199,7 @@ protected: void WarmNearbyCharacters(float DeltaSeconds); void UpdateFireRisk(float DeltaSeconds); float GetFireRiskGrowthPerSecond() const; + void UpdateVegetationIgnitionRisk(float DeltaSeconds); + float GetVegetationFuelScoreNearFire(float& OutGrassFuelScore, float& OutForestFuelScore) const; + float GetVegetationIgnitionWeatherMultiplier() const; }; diff --git a/Source/AgrarianGame/AgrarianFoliagePatch.cpp b/Source/AgrarianGame/AgrarianFoliagePatch.cpp index df16eea..e592d9a 100644 --- a/Source/AgrarianGame/AgrarianFoliagePatch.cpp +++ b/Source/AgrarianGame/AgrarianFoliagePatch.cpp @@ -106,3 +106,53 @@ int32 AAgrarianFoliagePatch::GetGrassInstanceCount() const { return GrassInstances ? GrassInstances->GetInstanceCount() : 0; } + +void AAgrarianFoliagePatch::GetFuelCountsNearLocation( + const FVector& WorldLocation, + float Radius, + int32& OutGrassCount, + int32& OutShrubCount, + int32& OutTreeCount) const +{ + SCOPE_CYCLE_COUNTER(STAT_AgrarianFoliageInstanceMutation); + TRACE_CPUPROFILER_EVENT_SCOPE(AgrarianFoliageFuelCountsNearLocation); + + OutGrassCount = CountInstancesNearLocation(GrassInstances, WorldLocation, Radius); + OutShrubCount = CountInstancesNearLocation(ShrubInstances, WorldLocation, Radius); + OutTreeCount = CountInstancesNearLocation(TreeInstances, WorldLocation, Radius); +} + +float AAgrarianFoliagePatch::GetDryVegetationFuelScoreNearLocation(const FVector& WorldLocation, float Radius) const +{ + int32 GrassCount = 0; + int32 ShrubCount = 0; + int32 TreeCount = 0; + GetFuelCountsNearLocation(WorldLocation, Radius, GrassCount, ShrubCount, TreeCount); + + return static_cast(GrassCount) + (static_cast(ShrubCount) * 2.0f) + (static_cast(TreeCount) * 4.0f); +} + +int32 AAgrarianFoliagePatch::CountInstancesNearLocation( + const UHierarchicalInstancedStaticMeshComponent* Component, + const FVector& WorldLocation, + float Radius) const +{ + if (!Component || Radius <= 0.0f) + { + return 0; + } + + const float RadiusSquared = FMath::Square(Radius); + int32 Count = 0; + for (int32 Index = 0; Index < Component->GetInstanceCount(); ++Index) + { + FTransform InstanceTransform; + if (Component->GetInstanceTransform(Index, InstanceTransform, true) + && FVector::DistSquared2D(InstanceTransform.GetLocation(), WorldLocation) <= RadiusSquared) + { + ++Count; + } + } + + return Count; +} diff --git a/Source/AgrarianGame/AgrarianFoliagePatch.h b/Source/AgrarianGame/AgrarianFoliagePatch.h index bd6374d..2b06348 100644 --- a/Source/AgrarianGame/AgrarianFoliagePatch.h +++ b/Source/AgrarianGame/AgrarianFoliagePatch.h @@ -49,4 +49,21 @@ public: UFUNCTION(BlueprintPure, Category = "Agrarian|Foliage") int32 GetGrassInstanceCount() const; + + UFUNCTION(BlueprintCallable, Category = "Agrarian|Foliage|Fire") + void GetFuelCountsNearLocation( + const FVector& WorldLocation, + float Radius, + int32& OutGrassCount, + int32& OutShrubCount, + int32& OutTreeCount) const; + + UFUNCTION(BlueprintPure, Category = "Agrarian|Foliage|Fire") + float GetDryVegetationFuelScoreNearLocation(const FVector& WorldLocation, float Radius) const; + +protected: + int32 CountInstancesNearLocation( + const UHierarchicalInstancedStaticMeshComponent* Component, + const FVector& WorldLocation, + float Radius) const; };