Add vegetation ignition risk checks

This commit is contained in:
2026-05-19 12:14:16 -07:00
parent 14cd8234e6
commit dca9d01f68
7 changed files with 311 additions and 1 deletions
+1 -1
View File
@@ -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.
+8
View File
@@ -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.
@@ -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()
+132
View File
@@ -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<FLifetimeProperty>& 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<AActor*> FoliageActors;
UGameplayStatics::GetAllActorsOfClass(this, AAgrarianFoliagePatch::StaticClass(), FoliageActors);
for (AActor* Actor : FoliageActors)
{
const AAgrarianFoliagePatch* FoliagePatch = Cast<AAgrarianFoliagePatch>(Actor);
if (!FoliagePatch)
{
continue;
}
int32 GrassCount = 0;
int32 ShrubCount = 0;
int32 TreeCount = 0;
FoliagePatch->GetFuelCountsNearLocation(GetActorLocation(), VegetationIgnitionCheckRadius, GrassCount, ShrubCount, TreeCount);
OutGrassFuelScore += static_cast<float>(GrassCount) + (static_cast<float>(ShrubCount) * 1.5f);
OutForestFuelScore += static_cast<float>(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<AAgrarianGameState>())
{
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);
}
+24
View File
@@ -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;
};
@@ -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<float>(GrassCount) + (static_cast<float>(ShrubCount) * 2.0f) + (static_cast<float>(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;
}
@@ -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;
};