Added session system
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// © 2025 Naked People Team. All Rights Reserved.
|
||||
|
||||
|
||||
#include "SessionLossResolver.h"
|
||||
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "NakedDesire/Clothing/ClothingItemInstance.h"
|
||||
#include "NakedDesire/Interactables/ItemPickup.h"
|
||||
#include "NakedDesire/SaveGame/GlobalSaveGameData.h"
|
||||
#include "NakedDesire/SaveGame/SaveSubsystem.h"
|
||||
|
||||
void USessionLossResolver::OnWorldBeginPlay(UWorld& InWorld)
|
||||
{
|
||||
Super::OnWorldBeginPlay(InWorld);
|
||||
|
||||
if (USessionManagerSubsystem* SessionManager = InWorld.GetSubsystem<USessionManagerSubsystem>())
|
||||
{
|
||||
SessionManager->OnSessionEnd.AddDynamic(this, &USessionLossResolver::ResolveLoss);
|
||||
}
|
||||
}
|
||||
|
||||
void USessionLossResolver::ResolveLoss(ESessionLossCause Cause)
|
||||
{
|
||||
// §4.4 precedence: if a chase is in progress, the loss always resolves as
|
||||
// police capture — the chase wins regardless of what actually fired.
|
||||
if (const USessionManagerSubsystem* SessionManager = GetWorld()->GetSubsystem<USessionManagerSubsystem>())
|
||||
{
|
||||
if (SessionManager->IsPoliceChaseActive())
|
||||
{
|
||||
Cause = ESessionLossCause::PoliceCapture;
|
||||
}
|
||||
}
|
||||
|
||||
// §4.4: equipped clothing is never forcibly removed — the resolver simply never
|
||||
// touches equipped items.
|
||||
// TODO(§6.4): when bags exist, a bag placed in the world is lost with its
|
||||
// contents — mark its world record for deletion here.
|
||||
|
||||
bool bWentToHoldingCell = false;
|
||||
|
||||
switch (Cause)
|
||||
{
|
||||
case ESessionLossCause::SafeReturn:
|
||||
// Normal session end; nothing is lost. Autosave below.
|
||||
break;
|
||||
|
||||
case ESessionLossCause::EmbarrassmentMax:
|
||||
// Fade to apartment with no extra cost — no time skip, money, or rep hit.
|
||||
break;
|
||||
|
||||
case ESessionLossCause::EnergyZero:
|
||||
// Forced sleep cycle: everything left outside the apartment is guaranteed lost.
|
||||
LoseAllWorldClothing();
|
||||
break;
|
||||
|
||||
case ESessionLossCause::PoliceCapture:
|
||||
{
|
||||
UGlobalSaveGameData* Save = GetSave();
|
||||
if (Save && Save->Money >= PoliceCaptureMoneyPenalty)
|
||||
{
|
||||
// Can pay: deduct the penalty and go home.
|
||||
Save->Money -= PoliceCaptureMoneyPenalty;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Can't pay: a night in the holding cell settles the debt; no money owed.
|
||||
bWentToHoldingCell = true;
|
||||
}
|
||||
ClearWanted();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
Autosave();
|
||||
|
||||
OnSessionLossResolved.Broadcast(Cause, bWentToHoldingCell);
|
||||
}
|
||||
|
||||
void USessionLossResolver::LoseAllWorldClothing()
|
||||
{
|
||||
UGlobalSaveGameData* Save = GetSave();
|
||||
|
||||
// Loose clothing in the world is represented by AItemPickup actors. Dropping only
|
||||
// happens outside, and the apartment stores items in the wardrobe (not as pickups),
|
||||
// so every pickup currently counts as "outside the apartment".
|
||||
// TODO: once an apartment volume query exists, spare pickups that sit inside it.
|
||||
TArray<AActor*> Pickups;
|
||||
UGameplayStatics::GetAllActorsOfClass(this, AItemPickup::StaticClass(), Pickups);
|
||||
for (AActor* Actor : Pickups)
|
||||
{
|
||||
AItemPickup* Pickup = Cast<AItemPickup>(Actor);
|
||||
if (!Pickup)
|
||||
continue;
|
||||
|
||||
if (Save)
|
||||
{
|
||||
if (UClothingItemInstance* Item = Pickup->GetItem())
|
||||
{
|
||||
Save->RemoveWorldItem(Item);
|
||||
}
|
||||
}
|
||||
Pickup->Destroy();
|
||||
}
|
||||
}
|
||||
|
||||
void USessionLossResolver::ClearWanted()
|
||||
{
|
||||
// TODO(§7.7 / Phase 6): clear the `wanted` tag once the Wanted attribute exists.
|
||||
}
|
||||
|
||||
void USessionLossResolver::Autosave() const
|
||||
{
|
||||
if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance())
|
||||
{
|
||||
if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem<USaveSubsystem>())
|
||||
{
|
||||
SaveSubsystem->SaveGame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UGlobalSaveGameData* USessionLossResolver::GetSave() const
|
||||
{
|
||||
if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance())
|
||||
{
|
||||
if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem<USaveSubsystem>())
|
||||
{
|
||||
return SaveSubsystem->GetCurrentSave();
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// © 2025 Naked People Team. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Subsystems/WorldSubsystem.h"
|
||||
#include "SessionManagerSubsystem.h"
|
||||
#include "SessionLossResolver.generated.h"
|
||||
|
||||
/**
|
||||
* Broadcast once a loss has been resolved transactionally. The presentation layer
|
||||
* (fade / cutscene / teleport) and the time skip live in Blueprint / the future
|
||||
* time subsystem and react to this, switching on FinalCause:
|
||||
* - EmbarrassmentMax → fade to apartment. No time skip.
|
||||
* - EnergyZero → trudge-home cutscene, then advance one sleep cycle.
|
||||
* - PoliceCapture (!cell) → fade to apartment.
|
||||
* - PoliceCapture (cell) → holding-cell cutscene, then fast-forward to next morning.
|
||||
* - SafeReturn → no special presentation.
|
||||
* The C++ side has already applied money / item / wanted changes by the time this fires.
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSessionLossResolvedSignature, ESessionLossCause, FinalCause, bool, bWentToHoldingCell);
|
||||
|
||||
/**
|
||||
* The single, deterministic owner of "what gets lost" on session end (GDD §4.4).
|
||||
* Subscribes to USessionManagerSubsystem::OnSessionEnd and routes every cause
|
||||
* through ResolveLoss. Per the architecture rule, no loss-handling logic should
|
||||
* live anywhere else.
|
||||
*/
|
||||
UCLASS()
|
||||
class NAKEDDESIRE_API USessionLossResolver : public UWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
|
||||
|
||||
// Single entry point for all §4.4 outcomes. Bound to OnSessionEnd; also callable
|
||||
// directly (e.g. from a debug command) to exercise a path.
|
||||
UFUNCTION(BlueprintCallable, Category = "Session")
|
||||
void ResolveLoss(ESessionLossCause Cause);
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Session")
|
||||
FOnSessionLossResolvedSignature OnSessionLossResolved;
|
||||
|
||||
private:
|
||||
// EnergyZero / sleep: every clothing item left outside the apartment is guaranteed lost.
|
||||
void LoseAllWorldClothing();
|
||||
|
||||
// §7.7: cleared on police capture. No-op until the Wanted attribute exists (Phase 6).
|
||||
void ClearWanted();
|
||||
|
||||
void Autosave() const;
|
||||
class UGlobalSaveGameData* GetSave() const;
|
||||
|
||||
// §21 tuning placeholder — the police-capture money penalty.
|
||||
float PoliceCaptureMoneyPenalty = 200.0f;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
// © 2025 Naked People Team. All Rights Reserved.
|
||||
|
||||
|
||||
#include "SessionManagerSubsystem.h"
|
||||
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "NakedDesire/Global/Constants.h"
|
||||
#include "NakedDesire/Player/NakedDesireCharacter.h"
|
||||
#include "NakedDesire/Stats/StatsManager.h"
|
||||
|
||||
void USessionManagerSubsystem::OnWorldBeginPlay(UWorld& InWorld)
|
||||
{
|
||||
Super::OnWorldBeginPlay(InWorld);
|
||||
|
||||
// The player pawn and its UStatsManager may not have finished BeginPlay when
|
||||
// the world begins play, so defer binding by one tick.
|
||||
InWorld.GetTimerManager().SetTimerForNextTick(this, &USessionManagerSubsystem::BindToPlayerStats);
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::BindToPlayerStats()
|
||||
{
|
||||
ANakedDesireCharacter* Player = Cast<ANakedDesireCharacter>(UGameplayStatics::GetPlayerCharacter(this, SLOT_PLAYER));
|
||||
if (!Player || !Player->StatsManager)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning, TEXT("USessionManagerSubsystem: no player StatsManager to bind to; loss detection disabled."));
|
||||
return;
|
||||
}
|
||||
|
||||
Player->StatsManager->EmbarrassmentUpdate.AddDynamic(this, &USessionManagerSubsystem::HandleEmbarrassmentUpdate);
|
||||
Player->StatsManager->EnergyUpdate.AddDynamic(this, &USessionManagerSubsystem::HandleEnergyUpdate);
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::NotifyEnteredApartment()
|
||||
{
|
||||
// Returning home is the safe end of a session (§4.3).
|
||||
if (bSessionActive)
|
||||
{
|
||||
EndSession(ESessionLossCause::SafeReturn);
|
||||
}
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::NotifyLeftApartment()
|
||||
{
|
||||
// Leaving the apartment is the only way to start a session (§4.1).
|
||||
if (!bSessionActive)
|
||||
{
|
||||
StartSession();
|
||||
}
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::StartSession()
|
||||
{
|
||||
bSessionActive = true;
|
||||
OnSessionStart.Broadcast();
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::EndSession(const ESessionLossCause Cause)
|
||||
{
|
||||
if (!bSessionActive)
|
||||
return;
|
||||
|
||||
bSessionActive = false;
|
||||
OnSessionEnd.Broadcast(Cause);
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::HandleEmbarrassmentUpdate(const float CurrentValue, const float MaxValue)
|
||||
{
|
||||
if (bSessionActive && MaxValue > 0.0f && CurrentValue >= MaxValue)
|
||||
{
|
||||
EndSession(ESessionLossCause::EmbarrassmentMax);
|
||||
}
|
||||
}
|
||||
|
||||
void USessionManagerSubsystem::HandleEnergyUpdate(const float CurrentValue, const float MaxValue)
|
||||
{
|
||||
if (bSessionActive && CurrentValue <= 0.0f)
|
||||
{
|
||||
EndSession(ESessionLossCause::EnergyZero);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// © 2025 Naked People Team. All Rights Reserved.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Subsystems/WorldSubsystem.h"
|
||||
#include "SessionManagerSubsystem.generated.h"
|
||||
|
||||
class UStatsManager;
|
||||
|
||||
/**
|
||||
* Why a session ended (GDD §4.4). SafeReturn is a non-loss end (player walked
|
||||
* home); the other three are the session-loss conditions from §3.3. The actual
|
||||
* "what gets lost" handling lives in USessionLossResolver (DEV-100), which also
|
||||
* applies police-chase precedence via bPoliceChaseActive.
|
||||
*/
|
||||
UENUM(BlueprintType)
|
||||
enum class ESessionLossCause : uint8
|
||||
{
|
||||
SafeReturn,
|
||||
EmbarrassmentMax,
|
||||
EnergyZero,
|
||||
PoliceCapture
|
||||
};
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnSessionStartSignature);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSessionEndSignature, ESessionLossCause, Cause);
|
||||
|
||||
/**
|
||||
* Owns the "going outside" session state (GDD §4). A session starts when the
|
||||
* player leaves the apartment and ends when they return safely or hit a loss
|
||||
* condition (embarrassment max / energy zero / police capture). The apartment
|
||||
* ALocationTrigger drives start / end; embarrassment-max and energy-zero are
|
||||
* detected by subscribing to UStatsManager. This replaces the EndGameEmbarrassed
|
||||
* Blueprint event on ANakedDesireGameMode.
|
||||
*/
|
||||
UCLASS()
|
||||
class NAKEDDESIRE_API USessionManagerSubsystem : public UWorldSubsystem
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
|
||||
|
||||
// Called by the apartment ALocationTrigger as the player crosses the threshold.
|
||||
void NotifyEnteredApartment();
|
||||
void NotifyLeftApartment();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Session")
|
||||
bool IsSessionActive() const { return bSessionActive; }
|
||||
|
||||
// Set by the police AI pipeline (Phase 6). Read by USessionLossResolver for
|
||||
// the §4.4 loss precedence rule: a loss fired mid-chase resolves as capture.
|
||||
UFUNCTION(BlueprintCallable, Category = "Session")
|
||||
void SetPoliceChaseActive(bool bActive) { bPoliceChaseActive = bActive; }
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Session")
|
||||
bool IsPoliceChaseActive() const { return bPoliceChaseActive; }
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Session")
|
||||
FOnSessionStartSignature OnSessionStart;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Session")
|
||||
FOnSessionEndSignature OnSessionEnd;
|
||||
|
||||
private:
|
||||
void StartSession();
|
||||
void EndSession(ESessionLossCause Cause);
|
||||
|
||||
void BindToPlayerStats();
|
||||
|
||||
UFUNCTION()
|
||||
void HandleEmbarrassmentUpdate(float CurrentValue, float MaxValue);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleEnergyUpdate(float CurrentValue, float MaxValue);
|
||||
|
||||
bool bSessionActive = false;
|
||||
bool bPoliceChaseActive = false;
|
||||
};
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "LocationTrigger.h"
|
||||
|
||||
#include "Components/BoxComponent.h"
|
||||
#include "NakedDesire/Global/SessionManagerSubsystem.h"
|
||||
#include "NakedDesire/Player/NakedDesireCharacter.h"
|
||||
|
||||
|
||||
ALocationTrigger::ALocationTrigger()
|
||||
@@ -19,3 +21,38 @@ ULocationData* ALocationTrigger::GetLocationData() const
|
||||
return LocationData;
|
||||
}
|
||||
|
||||
void ALocationTrigger::BeginPlay()
|
||||
{
|
||||
Super::BeginPlay();
|
||||
|
||||
if (bIsApartment)
|
||||
{
|
||||
BoxTrigger->OnComponentBeginOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerBeginOverlap);
|
||||
BoxTrigger->OnComponentEndOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerEndOverlap);
|
||||
}
|
||||
}
|
||||
|
||||
void ALocationTrigger::OnTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
|
||||
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
|
||||
{
|
||||
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
|
||||
return;
|
||||
|
||||
if (USessionManagerSubsystem* SessionManager = GetWorld()->GetSubsystem<USessionManagerSubsystem>())
|
||||
{
|
||||
SessionManager->NotifyEnteredApartment();
|
||||
}
|
||||
}
|
||||
|
||||
void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
|
||||
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
|
||||
{
|
||||
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
|
||||
return;
|
||||
|
||||
if (USessionManagerSubsystem* SessionManager = GetWorld()->GetSubsystem<USessionManagerSubsystem>())
|
||||
{
|
||||
SessionManager->NotifyLeftApartment();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,26 @@ class NAKEDDESIRE_API ALocationTrigger : public AActor
|
||||
UPROPERTY(EditAnywhere)
|
||||
ULocationData* LocationData;
|
||||
|
||||
// When set, the player crossing this trigger drives session start / end on
|
||||
// USessionManagerSubsystem (GDD §4.1 / §4.3). Exactly one trigger — the
|
||||
// apartment — should have this checked.
|
||||
UPROPERTY(EditAnywhere, Category = "Session")
|
||||
bool bIsApartment = false;
|
||||
|
||||
public:
|
||||
ALocationTrigger();
|
||||
|
||||
ULocationData* GetLocationData() const;
|
||||
|
||||
protected:
|
||||
virtual void BeginPlay() override;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void OnTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
|
||||
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);
|
||||
|
||||
UFUNCTION()
|
||||
void OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
|
||||
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
|
||||
#include "StatsManager.h"
|
||||
#include "Kismet/GameplayStatics.h"
|
||||
#include "NakedDesire/Global/NakedDesireGameMode.h"
|
||||
|
||||
UStatsManager::UStatsManager()
|
||||
{
|
||||
@@ -45,12 +43,9 @@ void UStatsManager::SetObserved(const bool bObserved, const float CoverageWeight
|
||||
void UStatsManager::IncreaseEmbarrassment(const float Amount)
|
||||
{
|
||||
Embarrassment = FMath::Clamp(Embarrassment + Amount, 0, MaxEmbarrassment);
|
||||
// Embarrassment-max session loss is handled by USessionManagerSubsystem, which
|
||||
// subscribes to EmbarrassmentUpdate (GDD §4.4). Broadcast is the integration point.
|
||||
EmbarrassmentUpdate.Broadcast(Embarrassment, MaxEmbarrassment);
|
||||
if (Embarrassment == MaxEmbarrassment)
|
||||
{
|
||||
ANakedDesireGameMode* GameMode = Cast<ANakedDesireGameMode>(UGameplayStatics::GetGameMode(this));
|
||||
GameMode->EndGameEmbarrassed();
|
||||
}
|
||||
}
|
||||
|
||||
void UStatsManager::DecreaseEmbarrassment(const float Amount)
|
||||
|
||||
Reference in New Issue
Block a user