LifePlay-Revival/Source/LifePlay/LPBonusRewardSubsystem.cpp

1029 lines
37 KiB
C++
Raw Permalink Normal View History

// Lot STE, fiches ORG5-I1 et ORG5-I2a (affichage) - voir LPBonusRewardSubsystem.h.
// Origine : comportement reconstruit d'apres LifePlay 2023.04 de Vinfamy. Licence MIT
// (SPDX-License-Identifier: MIT), texte de reference : Licenses/Vinfamy_License.txt.
#include "LPBonusRewardSubsystem.h"
#include "Blueprint/UserWidget.h"
#include "Blueprint/WidgetTree.h"
#include "Components/CanvasPanelSlot.h"
#include "Components/PanelWidget.h"
#include "Components/TextBlock.h"
#include "Components/VerticalBox.h"
#include "Components/VerticalBoxSlot.h"
#include "Engine/GameInstance.h"
#include "HAL/PlatformTime.h"
#include "Kismet/KismetMathLibrary.h"
#include "LifePlay.h"
#include "Styling/CoreStyle.h"
#include "UObject/TextProperty.h"
#include "UObject/UnrealType.h"
namespace LPBonusReward
{
constexpr float PollSeconds = 0.1f; // sondage H1 (STE-ORG5-SPEC.md 2.2)
constexpr double DebounceSeconds = 0.2; // RetriggerableDelay(0.2) de l'auteur (SPEC 1.1 E3)
constexpr int32 CandidateCount = 10; // Array_Resize(10) apres Array_Shuffle (SPEC 1.1 E5)
constexpr int32 MaxRewards = 3; // NotEqual_IntInt(countBR, 3) (SPEC 1.1 E6e)
const TCHAR* const AttractionKey = TEXT("attractiontoplayer");
// Affichage (fiche ORG5-I2a, STE-ORG5-I2-SPEC.md 2.2 et decisions de l'utilisateur).
constexpr double RewardLineSeconds = 20.0; // Q-I2-2 : chaque ligne retiree 20 s apres son affichage
constexpr int32 MaxRewardLines = 1; // V-min : une ligne, remplacee a la recompense suivante (SPEC 2.2, V6-3)
constexpr double EquipMenuMinHeight = 500.0; // Q-I2-3 : hauteur d'EquipMenu chez l'auteur (SPEC 1.1 A3)
constexpr float RewardFontSize = 16.f; // RewardText de l'auteur (SPEC 1.1 A5)
constexpr int32 RewardOutlineSize = 2; // idem, contour noir alpha 0,8
const TCHAR* const CursorProp = TEXT("Cursor");
const TCHAR* const EquipMenuName = TEXT("EquipMenu");
const TCHAR* const ActionTextName = TEXT("ActionText");
const TCHAR* const RewardBoxName = TEXT("LPBonusRewardBox");
// Retire une ligne de l'ecran si elle existe encore (pas d'acces apres destruction).
void RemoveLine(const FLPBonusRewardLine& Entry)
{
UTextBlock* Line = Entry.Line.Get();
if (IsValid(Line))
{
Line->RemoveFromParent();
}
}
// Police de la ligne : copiee d'ActionText (texte voisin dans EquipMenu, SPEC 1.4 N1), comme
// LPColorGradeSubsystem.cpp:295, sinon police par defaut de Slate ; puis taille 16 et contour 2 noir 0,8 de
// l'auteur (SPEC 1.1 A5). FSlateFontInfo::Size et OutlineSettings : SlateFontInfo.h:175, :160 ;
// FFontOutlineSettings(int32, FLinearColor) : SlateFontInfo.h:78.
FSlateFontInfo RewardFont(const UUserWidget* Cursor)
{
const UTextBlock* Sample = Cast<UTextBlock>(Cursor->GetWidgetFromName(FName(ActionTextName)));
FSlateFontInfo Font = Sample ? Sample->GetFont() : FCoreStyle::GetDefaultFontStyle("Regular", RewardFontSize);
Font.Size = RewardFontSize;
Font.OutlineSettings = FFontOutlineSettings(RewardOutlineSize, FLinearColor(0.f, 0.f, 0.f, 0.8f));
return Font;
}
FProperty* FindProp(const UObject* Owner, const TCHAR* Name)
{
return Owner ? Owner->GetClass()->FindPropertyByName(FName(Name)) : nullptr;
}
// Parametre d'une fonction Blueprint, par nom (comme LPDirectControlSubsystem.cpp:375-385).
FProperty* FindParm(UFunction* Function, const TCHAR* Name)
{
for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
{
if (It->GetAuthoredName() == Name || It->GetName() == Name)
{
return *It;
}
}
return nullptr;
}
// Liste lisible des parametres, pour l'avertissement unique quand la signature n'est pas celle attendue.
FString DescribeParms(UFunction* Function)
{
FString Out;
for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
{
if (!Out.IsEmpty())
{
Out += TEXT(", ");
}
Out += FString::Printf(TEXT("%s (%s%s)"), *It->GetAuthoredName(), *It->GetClass()->GetName(),
It->HasAnyPropertyFlags(CPF_OutParm) ? TEXT(", sortie") : TEXT(""));
}
return Out;
}
// Reel Blueprint : double en 5.x (FDoubleProperty), float accepte aussi (comme LPUserSettings.cpp:32-55).
bool IsReal(const FProperty* Prop)
{
return CastField<FDoubleProperty>(Prop) || CastField<FFloatProperty>(Prop);
}
double ReadReal(const FProperty* Prop, const void* Container)
{
if (const FDoubleProperty* AsDouble = CastField<FDoubleProperty>(Prop))
{
return AsDouble->GetPropertyValue_InContainer(Container);
}
if (const FFloatProperty* AsFloat = CastField<FFloatProperty>(Prop))
{
return static_cast<double>(AsFloat->GetPropertyValue_InContainer(Container));
}
return 0.0;
}
void WriteReal(const FProperty* Prop, void* Container, double Value)
{
if (const FDoubleProperty* AsDouble = CastField<FDoubleProperty>(Prop))
{
AsDouble->SetPropertyValue_InContainer(Container, Value);
}
else if (const FFloatProperty* AsFloat = CastField<FFloatProperty>(Prop))
{
AsFloat->SetPropertyValue_InContainer(Container, static_cast<float>(Value));
}
}
// Booleen d'entree d'une fonction : present et booleen, sinon faux (l'appelant avertit et abandonne).
bool SetBoolParm(UFunction* Function, void* Parms, const TCHAR* Name, bool bValue)
{
const FBoolProperty* Prop = CastField<FBoolProperty>(FindParm(Function, Name));
if (!Prop)
{
return false;
}
Prop->SetPropertyValue_InContainer(Parms, bValue);
return true;
}
// Parametres d'un appel par ProcessEvent : memoire fournie par l'appelant (FMemory_Alloca_Aligned, qui doit rester
// dans le cadre de l'appelant), mise a zero et initialisee ici, detruite a la sortie de portee
// (meme sequence que LPDirectControlSubsystem.cpp:426-455).
struct FScopedParms
{
UFunction* Function;
uint8* Memory;
FScopedParms(UFunction* InFunction, uint8* InMemory)
: Function(InFunction)
, Memory(InMemory)
{
FMemory::Memzero(Memory, Function->ParmsSize);
for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
{
It->InitializeValue_InContainer(Memory);
}
}
~FScopedParms()
{
for (TFieldIterator<FProperty> It(Function); It && It->HasAnyPropertyFlags(CPF_Parm); ++It)
{
It->DestroyValue_InContainer(Memory);
}
}
FScopedParms(const FScopedParms&) = delete;
FScopedParms& operator=(const FScopedParms&) = delete;
};
// Proprietes lues par reflexion. Faux : propriete absente ou d'un autre type (l'appelant avertit).
bool ReadObject(const UObject* Owner, const TCHAR* Name, UObject*& Out)
{
Out = nullptr;
const FObjectPropertyBase* Prop = CastField<FObjectPropertyBase>(FindProp(Owner, Name));
if (!Prop)
{
return false;
}
Out = Prop->GetObjectPropertyValue_InContainer(Owner);
return true;
}
// Tableau d'objets (comme LPShowCoupleSubsystem.cpp:80-96).
bool ReadObjects(const UObject* Owner, const TCHAR* Name, TArray<UObject*>& Out)
{
Out.Reset();
const FArrayProperty* ArrayProp = CastField<FArrayProperty>(FindProp(Owner, Name));
const FObjectPropertyBase* Inner = ArrayProp ? CastField<FObjectPropertyBase>(ArrayProp->Inner) : nullptr;
if (!Inner)
{
return false;
}
FScriptArrayHelper Helper(ArrayProp, ArrayProp->ContainerPtrToValuePtr<void>(Owner));
for (int32 Index = 0; Index < Helper.Num(); ++Index)
{
Out.Add(Inner->GetObjectPropertyValue(Helper.GetRawPtr(Index)));
}
return true;
}
// Tableau de chaines (GI.CurrentTags). D2 (STE-ORG5-V1-VAL.md) : type reel non lu, elements chaine, nom ou texte
// acceptes et convertis en chaines.
bool ReadStrings(const UObject* Owner, const TCHAR* Name, TArray<FString>& Out)
{
Out.Reset();
const FArrayProperty* ArrayProp = CastField<FArrayProperty>(FindProp(Owner, Name));
if (!ArrayProp)
{
return false;
}
const FStrProperty* AsStr = CastField<FStrProperty>(ArrayProp->Inner);
const FNameProperty* AsName = CastField<FNameProperty>(ArrayProp->Inner);
const FTextProperty* AsText = CastField<FTextProperty>(ArrayProp->Inner);
if (!AsStr && !AsName && !AsText)
{
return false;
}
FScriptArrayHelper Helper(ArrayProp, ArrayProp->ContainerPtrToValuePtr<void>(Owner));
Out.Reserve(Helper.Num());
for (int32 Index = 0; Index < Helper.Num(); ++Index)
{
const void* Item = Helper.GetRawPtr(Index);
if (AsStr)
{
Out.Add(AsStr->GetPropertyValue(Item));
}
else if (AsName)
{
Out.Add(AsName->GetPropertyValue(Item).ToString());
}
else
{
Out.Add(AsText->GetPropertyValue(Item).ToString());
}
}
return true;
}
}
void ULPBonusRewardSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
Rng.GenerateNewSeed();
FString Failure;
if (CheckFormula(Failure))
{
UE_LOG(LogLifePlay, Log, TEXT("Bonus : contrôle formule OK"));
}
else
{
UE_LOG(LogLifePlay, Error, TEXT("Bonus : contrôle formule en erreur, %s"), *Failure);
}
UE_LOG(LogLifePlay, Log, TEXT("Bonus : réglage bDisabled = %s ([/Script/LifePlay.LPBonusRewardSettings], config Game)"),
GetDefault<ULPBonusRewardSettings>()->bDisabled ? TEXT("vrai") : TEXT("faux"));
TickHandle = FTSTicker::GetCoreTicker().AddTicker(
FTickerDelegate::CreateUObject(this, &ULPBonusRewardSubsystem::Tick), LPBonusReward::PollSeconds);
}
void ULPBonusRewardSubsystem::Deinitialize()
{
FTSTicker::GetCoreTicker().RemoveTicker(TickHandle);
Pending.Reset();
LastCounts.Reset();
// Garde 3 : minuteries des lignes videes ; les widgets ne sont pas touches ici (ils partent avec le monde et le
// GameInstance en cours de fermeture).
ClearRewardDisplay(false);
RewardBox.Reset();
Super::Deinitialize();
}
const TArray<FLPBonusStat>& ULPBonusRewardSubsystem::Table()
{
// STE-ORG5-SPEC.md section 1.2 (valeur par defaut de Cursor.statTags chez l'auteur), meme ordre.
static const TArray<FLPBonusStat> Stats = {
{ TEXT("perversion"), {} },
{ TEXT("masochist"), { TEXT("Aggressive"), TEXT("AggressiveFM") } },
{ TEXT("prone_to_orgasm"), {} },
{ TEXT("attractiontoplayer"), {} },
{ TEXT("fitness"), { TEXT("Penetration"), TEXT("Foreplay"), TEXT("Cum") } },
{ TEXT("likes_anal"), { TEXT("Anal") } },
{ TEXT("likes_boobjob"), { TEXT("Titfuck"), TEXT("Boobjob") } },
{ TEXT("likes_cowgirl"), { TEXT("Cow"), TEXT("Cowgirl") } },
{ TEXT("likes_doggy"), { TEXT("Doggy"), TEXT("Doggystyle") } },
{ TEXT("likes_handjob"), { TEXT("Handjob"), TEXT("RubPussy"), TEXT("FingerPussy") } },
{ TEXT("likes_missionary"), { TEXT("Missionary") } },
{ TEXT("likes_oral"), { TEXT("Oral"), TEXT("Blowjob"), TEXT("Cunnilingus"), TEXT("LickPussy"), TEXT("LickAss"), TEXT("Rimjob") } },
{ TEXT("likes_rcowgirl"), { TEXT("ReverseCow"), TEXT("ReverseCowgirl") } },
{ TEXT("likes_rough"), { TEXT("Aggressive"), TEXT("AggressiveFM") } },
{ TEXT("likes_sidefuck"), { TEXT("Spooning"), TEXT("Sidefuck"), TEXT("Spoon") } },
{ TEXT("likes_toys"), { TEXT("Toys"), TEXT("Dildo"), TEXT("Vibrator") } },
{ TEXT("likes_tribadism"), { TEXT("Tribadism"), TEXT("Scissor"), TEXT("Scissoring") } },
{ TEXT("likes_vaginal"), { TEXT("Vaginal") } },
{ TEXT("masochist-"), { TEXT("Aggressive"), TEXT("AggressiveFM") } },
{ TEXT("prone_to_orgasm-"), { TEXT("Penetration"), TEXT("Foreplay"), TEXT("Cum") } },
};
return Stats;
}
double ULPBonusRewardSubsystem::RewardChange(double X, bool bNegative)
{
// Ordre de l'auteur (SPEC 1.3) : Abs, MapRangeClamped(0, 100, 0, 1.9), 2 - m, * 10, FCeil, entier -> reel, / 10,
// * signe (SelectFloat(-1, 1, EndsWith("-"))). Memes fonctions que les noeuds Blueprint (KismetMathLibrary.h:796,
// :836), en double comme les Blueprints 5.x.
const double Mapped = UKismetMathLibrary::MapRangeClamped(FMath::Abs(X), 0.0, 100.0, 0.0, 1.9);
const int32 Tenths = UKismetMathLibrary::FCeil((2.0 - Mapped) * 10.0);
const double Value = static_cast<double>(Tenths) / 10.0;
return Value * (bNegative ? -1.0 : 1.0);
}
bool ULPBonusRewardSubsystem::CheckFormula(FString& OutFailure)
{
struct FCase
{
double X;
bool bNegative;
double Expected;
};
// SPEC 2.4 : 0, 10, 25, 50, 75, 90, 100 -> 2.0, 1.9, 1.6, 1.1, 0.6, 0.3, 0.2 ; plus le signe d'une cle en '-'.
static const FCase Cases[] = {
{ 0.0, false, 2.0 }, { 10.0, false, 1.9 }, { 25.0, false, 1.6 }, { 50.0, false, 1.1 },
{ 75.0, false, 0.6 }, { 90.0, false, 0.3 }, { 100.0, false, 0.2 }, { 50.0, true, -1.1 },
};
for (const FCase& Case : Cases)
{
const double Got = RewardChange(Case.X, Case.bNegative);
if (!FMath::IsNearlyEqual(Got, Case.Expected, 1e-9))
{
OutFailure = FString::Printf(TEXT("x=%.1f%s : %.17g au lieu de %.1f"),
Case.X, Case.bNegative ? TEXT(" (baisse)") : TEXT(""), Got, Case.Expected);
return false;
}
}
return true;
}
bool ULPBonusRewardSubsystem::Overlaps(const TArray<FString>& A, const TArray<FString>& B, bool bIfEmptyThenTrue)
{
// GI.Overlaps (L6f_dump_GI.txt:19622-19673) : IfEmptyThenTrue ET (A vide OU B vide) -> vrai ; sinon un element de
// A contenu dans B (Array_Contains) -> vrai ; sinon faux.
if (bIfEmptyThenTrue && (A.Num() == 0 || B.Num() == 0))
{
return true;
}
for (const FString& Item : A)
{
if (B.Contains(Item))
{
return true;
}
}
return false;
}
void ULPBonusRewardSubsystem::Pick(const TArray<FString>& PositionTags, FRandomStream& InRng, bool bAttractionAllowed,
TArray<FString>& OutStats, TArray<bool>& OutNegative)
{
OutStats.Reset();
OutNegative.Reset();
const TArray<FLPBonusStat>& Stats = Table();
// Map_Keys(statTags) puis Array_Shuffle : meme algorithme que KismetArrayLibrary.cpp:338-352 (ici par le flux
// InRng, graine injectable), puis Array_Resize(10).
TArray<int32> Order;
Order.Reserve(Stats.Num());
for (int32 Index = 0; Index < Stats.Num(); ++Index)
{
Order.Add(Index);
}
const int32 LastIndex = Order.Num() - 1;
for (int32 i = 0; i <= LastIndex; ++i)
{
const int32 Index = InRng.RandRange(i, LastIndex);
if (i != Index)
{
Order.Swap(i, Index);
}
}
Order.SetNum(FMath::Min(Order.Num(), LPBonusReward::CandidateCount));
for (const int32 Index : Order)
{
const FLPBonusStat& Entry = Stats[Index];
if (!Overlaps(PositionTags, Entry.Tags, true))
{
continue;
}
if (Entry.Key == LPBonusReward::AttractionKey && !bAttractionAllowed)
{
continue;
}
const bool bNegative = Entry.Key.EndsWith(TEXT("-"));
OutStats.Add(bNegative ? Entry.Key.LeftChop(1) : Entry.Key);
OutNegative.Add(bNegative);
if (OutStats.Num() == LPBonusReward::MaxRewards)
{
break;
}
}
}
bool ULPBonusRewardSubsystem::Tick(float DeltaTime)
{
(void)DeltaTime;
if (UGameInstance* GI = GetGameInstance())
{
Poll(GI);
}
FirePending();
ExpireRewardLines();
return true;
}
void ULPBonusRewardSubsystem::Poll(UObject* GI)
{
const FBoolProperty* SexOn = CastField<FBoolProperty>(LPBonusReward::FindProp(GI, TEXT("SexOn")));
if (!SexOn)
{
// D1 (STE-ORG5-V1-VAL.md) : message construit seulement au premier passage (Poll tourne toutes les 0,1 s).
if (FirstWarning(TEXT("GI.SexOn")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : pas de propriete booleenne SexOn dans %s, aucun orgasme detecte"),
*GI->GetClass()->GetName());
}
bInScene = false;
ClearRewardDisplay(true);
LastCounts.Reset();
return;
}
bInScene = SexOn->GetPropertyValue_InContainer(GI);
if (!bInScene)
{
// Hors scene : memoire des compteurs videe. Un orgasme deja detecte (Pending) garde son tempo, comme le
// RetriggerableDelay de l'auteur qui ne depend pas de la scene. Garde 3 (ORG5-I2a) : lignes de recompense
// retirees de l'ecran et minuteries videes a la sortie de scene.
LastCounts.Reset();
ClearRewardDisplay(true);
return;
}
TArray<UObject*> Persons;
if (!LPBonusReward::ReadObjects(GI, TEXT("SexPersons"), Persons))
{
if (FirstWarning(TEXT("GI.SexPersons")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : pas de tableau d'objets SexPersons dans %s, aucun orgasme detecte"),
*GI->GetClass()->GetName());
}
LastCounts.Reset();
return;
}
TSet<TWeakObjectPtr<UObject>> Seen;
for (UObject* Person : Persons)
{
if (!IsValid(Person))
{
continue;
}
const FIntProperty* CountProp = CastField<FIntProperty>(LPBonusReward::FindProp(Person, TEXT("Sex_OrgasmCounts")));
if (!CountProp)
{
if (FirstWarning(TEXT("Person.Sex_OrgasmCounts")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : pas de propriete entiere Sex_OrgasmCounts dans %s, personne ignoree"),
*Person->GetClass()->GetName());
}
continue;
}
const int32 Count = CountProp->GetPropertyValue_InContainer(Person);
const TWeakObjectPtr<UObject> Key(Person);
Seen.Add(Key);
int32* Last = LastCounts.Find(Key);
if (!Last)
{
// Premier passage (debut de scene, personne qui entre) : memorisation sans recompense.
LastCounts.Add(Key, Count);
continue;
}
if (Count > *Last)
{
TArray<FString> Tags;
if (!LPBonusReward::ReadStrings(GI, TEXT("CurrentTags"), Tags))
{
WarnOnce(TEXT("GI.CurrentTags"), FString::Printf(TEXT("pas de tableau de chaines CurrentTags dans %s, orgasme sans recompense"),
*GI->GetClass()->GetName()));
}
else
{
if (Pending.IsSet())
{
UE_LOG(LogLifePlay, Verbose, TEXT("Bonus : orgasme en attente remplace par celui de %s (tempo %.1f s)"),
*PersonName(Person), LPBonusReward::DebounceSeconds);
}
FLPBonusPending NewPending;
NewPending.Person = Person;
NewPending.Tags = MoveTemp(Tags);
NewPending.Count = Count;
NewPending.FireAt = FPlatformTime::Seconds() + LPBonusReward::DebounceSeconds;
Pending = MoveTemp(NewPending);
}
}
// Hausse : nouveau compteur retenu ; baisse (remise a zero du jeu) : resynchronisation sans recompense.
*Last = Count;
}
// Personne sortie de la scene : oubliee (si elle revient, premier passage sans recompense).
for (auto It = LastCounts.CreateIterator(); It; ++It)
{
if (!Seen.Contains(It.Key()))
{
It.RemoveCurrent();
}
}
}
void ULPBonusRewardSubsystem::FirePending()
{
if (!Pending.IsSet() || FPlatformTime::Seconds() < Pending->FireAt)
{
return;
}
const FLPBonusPending Fire = MoveTemp(Pending.GetValue());
Pending.Reset();
UObject* Person = Fire.Person.Get();
if (!IsValid(Person))
{
UE_LOG(LogLifePlay, Verbose, TEXT("Bonus : personne disparue avant la fin du tempo, pas de recompense"));
return;
}
OnOrgasm(Person, Fire.Tags, Fire.Count);
}
void ULPBonusRewardSubsystem::OnOrgasm(UObject* Person, const TArray<FString>& TagsCapturedAtDetection, int32 Count)
{
const FString Name = PersonName(Person);
UE_LOG(LogLifePlay, Log, TEXT("Bonus : orgasme de %s (compteur %d)"), *Name, Count);
if (GetDefault<ULPBonusRewardSettings>()->bDisabled)
{
UE_LOG(LogLifePlay, Log, TEXT("Bonus : désactivé (bDisabled)"));
return;
}
UGameInstance* GI = GetGameInstance();
const bool bAttraction = GI && AttractionAllowed(GI, Person);
TArray<FString> Stats;
TArray<bool> Negative;
Pick(TagsCapturedAtDetection, Rng, bAttraction, Stats, Negative);
if (Stats.Num() == 0)
{
UE_LOG(LogLifePlay, Log, TEXT("Bonus : aucune récompense éligible pour %s"), *Name);
return;
}
// V-min : la premiere recompense retenue est appliquee (l'auteur en propose jusqu'a 3 et le joueur choisit).
double X = 0.0;
if (!QueryStat(Person, Stats[0], X))
{
return;
}
const double Change = RewardChange(X, Negative[0]);
if (!ModifyStat(Person, Stats[0], Change))
{
return;
}
UE_LOG(LogLifePlay, Log, TEXT("Bonus : %s %s %+.1f (x=%.2f, tags=%d)"),
*Name, *Stats[0], Change, X, TagsCapturedAtDetection.Num());
// ORG5-I2a : ligne affichee dans le menu d'actions (EquipMenu de Cursor) ; un echec d'affichage laisse la
// recompense appliquee (garde 1). bDisabled est sorti plus haut : rien n'est affiche (Q-I2-4).
ShowAppliedReward(Name, Stats[0], Change);
}
void ULPBonusRewardSubsystem::ShowAppliedReward(const FString& Name, const FString& Stat, double Change)
{
// STE-ORG5-I2-SPEC.md option (a), section 2.2 : la ligne va dans une case ajoutee comme dernier enfant d'EquipMenu
// (emplacement de BonusRewardBox chez l'auteur, SPEC 1.1 A1 a A4) ; elle suit donc l'ouverture du menu et la
// visibilite d'EquipMenu (liee a GI.SexOn, SPEC 1.1 A7) sans code de plus. Appel depuis FirePending (ticker).
if (!bInScene)
{
// Tempo de 0,2 s fini apres la fin de la scene : pas de ligne (elle serait retiree au sondage suivant).
UE_LOG(LogLifePlay, Verbose, TEXT("Bonus : scene terminee avant l'affichage, ligne non affichee"));
return;
}
UGameInstance* GI = GetGameInstance();
if (!GI)
{
return;
}
UUserWidget* Cursor = FindCursor(GI);
if (!Cursor)
{
return;
}
UPanelWidget* Equip = FindEquipMenu(Cursor);
if (!Equip)
{
return;
}
EnsureEquipMenuHeight(Equip);
UVerticalBox* Box = EnsureRewardBox(Cursor, Equip);
if (!Box)
{
return;
}
// Remplacement (SPEC 2.2 point 4, V6-3) : au plus MaxRewardLines lignes, la plus ancienne retiree d'abord.
while (RewardLines.Num() != 0 && RewardLines.Num() >= LPBonusReward::MaxRewardLines)
{
LPBonusReward::RemoveLine(RewardLines[0]);
RewardLines.RemoveAt(0);
}
// Ligne creee dans l'arbre de widgets de Cursor (UWidgetTree::ConstructWidget, WidgetTree.h:106-117 :
// NewObject avec l'arbre pour outer), nom automatique.
UTextBlock* Line = Cursor->WidgetTree->ConstructWidget<UTextBlock>(UTextBlock::StaticClass());
if (!Line)
{
return;
}
// Meme format que la ligne de journal ci-dessus (%+.1f, SPEC Q-I2-6).
Line->SetText(FText::FromString(FString::Printf(TEXT("Bonus : %s %s %+.1f"), *Name, *Stat, Change)));
Line->SetFont(LPBonusReward::RewardFont(Cursor));
Line->SetColorAndOpacity(FSlateColor(FLinearColor::White));
if (UVerticalBoxSlot* LineSlot = Box->AddChildToVerticalBox(Line))
{
// Marges de ScrollBonuses chez l'auteur (SPEC 1.1 A4) : gauche 10, haut 5, droite 10.
LineSlot->SetPadding(FMargin(10.f, 5.f, 10.f, 0.f));
LineSlot->SetHorizontalAlignment(HAlign_Left);
}
FLPBonusRewardLine Entry;
Entry.Line = Line;
Entry.ExpireAt = FPlatformTime::Seconds() + LPBonusReward::RewardLineSeconds;
RewardLines.Add(Entry);
UE_LOG(LogLifePlay, Verbose, TEXT("Bonus : ligne affichee pour %.0f s"), LPBonusReward::RewardLineSeconds);
}
UUserWidget* ULPBonusRewardSubsystem::FindCursor(UObject* GI)
{
// Garde 1 : GI.Cursor est une propriete Blueprint, lue par reflexion comme GI.PlayerCharacter ; type reel non lu,
// verifie par Cast en UUserWidget. Introuvable : un avertissement par cause, la recompense reste appliquee.
UObject* Value = nullptr;
if (!LPBonusReward::ReadObject(GI, LPBonusReward::CursorProp, Value))
{
if (FirstWarning(TEXT("GI.Cursor")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : pas de propriete objet Cursor dans %s, recompense appliquee sans affichage"),
*GI->GetClass()->GetName());
}
return nullptr;
}
UUserWidget* Cursor = Cast<UUserWidget>(Value);
if (!IsValid(Cursor) || !Cursor->WidgetTree)
{
if (FirstWarning(TEXT("GI.Cursor.Value")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : GI.Cursor vide, sans arbre de widgets ou d'une classe autre que UserWidget (%s), recompense appliquee sans affichage"),
*GetNameSafe(Value ? Value->GetClass() : nullptr));
}
return nullptr;
}
return Cursor;
}
UPanelWidget* ULPBonusRewardSubsystem::FindEquipMenu(UUserWidget* Cursor)
{
// UUserWidget::GetWidgetFromName = WidgetTree->FindWidget(Name) (UserWidget.cpp:1259-1262), parcours de l'arbre
// (WidgetTree.cpp:40-54) : trouve aussi un widget qui n'est pas une variable. Type verifie par Cast (garde 1).
UWidget* Found = Cursor->GetWidgetFromName(FName(LPBonusReward::EquipMenuName));
UPanelWidget* Equip = Cast<UPanelWidget>(Found);
if (!IsValid(Equip))
{
if (FirstWarning(TEXT("Cursor.EquipMenu")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : pas de panneau EquipMenu dans %s (trouve : %s), recompense appliquee sans affichage"),
*Cursor->GetClass()->GetName(), *GetNameSafe(Found ? Found->GetClass() : nullptr));
}
return nullptr;
}
return Equip;
}
void ULPBonusRewardSubsystem::EnsureEquipMenuHeight(UPanelWidget* Equip)
{
// Q-I2-3 : hauteur de l'auteur (500, SPEC 1.1 A3) si elle est inferieure (350 chez nous, SPEC 1.4 N1). EquipMenu
// est enfant du CanvasPanel BelowCursor : sa hauteur est celle de son slot (UCanvasPanelSlot::GetSize / SetSize,
// CanvasPanelSlot.h:116, :112), qui n'a ce sens que sans taille automatique (GetAutoSize, :148) et sans ancres
// etirees verticalement (GetAnchors, :132 ; FAnchors::IsStretchedVertical, Anchors.h:78). Sinon : rien change.
UPanelSlot* EquipSlot = Equip->Slot.Get();
UCanvasPanelSlot* CanvasSlot = Cast<UCanvasPanelSlot>(EquipSlot);
if (!CanvasSlot)
{
if (FirstWarning(TEXT("EquipMenu.Slot")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : EquipMenu n'est pas dans un CanvasPanel (slot %s), hauteur inchangee"),
*GetNameSafe(EquipSlot ? EquipSlot->GetClass() : nullptr));
}
return;
}
if (CanvasSlot->GetAutoSize() || CanvasSlot->GetAnchors().IsStretchedVertical())
{
if (FirstWarning(TEXT("EquipMenu.Slot.Layout")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : slot d'EquipMenu en taille automatique ou etire verticalement, hauteur inchangee"));
}
return;
}
const FVector2D Size = CanvasSlot->GetSize();
if (Size.Y < LPBonusReward::EquipMenuMinHeight)
{
CanvasSlot->SetSize(FVector2D(Size.X, LPBonusReward::EquipMenuMinHeight));
UE_LOG(LogLifePlay, Log, TEXT("Bonus : hauteur d'EquipMenu portee de %.0f a %.0f"), Size.Y, LPBonusReward::EquipMenuMinHeight);
}
}
UVerticalBox* ULPBonusRewardSubsystem::EnsureRewardBox(UUserWidget* Cursor, UPanelWidget* Equip)
{
// Garde 2 (aucun ajout en double) : la case est retrouvee avant toute creation, dans l'ordre : pointeur faible
// encore enfant de cet EquipMenu ; enfant d'EquipMenu de ce nom ; objet de ce nom dans l'arbre de Cursor (case
// detachee, reprise au lieu d'un NewObject du meme nom). Creee seulement si aucune n'existe.
UVerticalBox* Box = RewardBox.Get();
if (IsValid(Box) && Box->GetParent() == Equip)
{
return Box;
}
Box = nullptr;
const FName BoxName(LPBonusReward::RewardBoxName);
bool bAttached = false;
for (int32 Index = 0; Index < Equip->GetChildrenCount(); ++Index)
{
UWidget* Child = Equip->GetChildAt(Index);
if (Child && Child->GetFName() == BoxName)
{
Box = Cast<UVerticalBox>(Child);
bAttached = true;
break;
}
}
UWidgetTree* Tree = Cursor->WidgetTree.Get();
if (!bAttached)
{
// FindObjectFast<T>(Outer, Name) : UObjectGlobals.h:2059 ; UObject pour voir un objet de ce nom de toute classe.
if (UObject* Existing = FindObjectFast<UObject>(Tree, BoxName))
{
Box = Cast<UVerticalBox>(Existing);
if (IsValid(Box))
{
Box->RemoveFromParent();
}
}
else
{
Box = Tree->ConstructWidget<UVerticalBox>(UVerticalBox::StaticClass(), BoxName);
}
}
if (!IsValid(Box))
{
if (FirstWarning(TEXT("LPBonusRewardBox.Type")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : un objet %s existe deja sans etre une VerticalBox utilisable, recompense appliquee sans affichage"),
LPBonusReward::RewardBoxName);
}
return nullptr;
}
if (!bAttached)
{
// UPanelWidget::AddChild (PanelWidget.h:59) ajoute en dernier : meme indice que BonusRewardBox chez l'auteur.
UPanelSlot* BoxSlot = Equip->AddChild(Box);
if (!BoxSlot)
{
if (FirstWarning(TEXT("EquipMenu.AddChild")))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : EquipMenu (%s) refuse un enfant de plus, recompense appliquee sans affichage"),
*Equip->GetClass()->GetName());
}
return nullptr;
}
// Slot Fill comme BonusRewardBox chez l'auteur (SPEC 1.1 A4) ; autre panneau que VerticalBox : slot laisse tel quel.
if (UVerticalBoxSlot* VerticalSlot = Cast<UVerticalBoxSlot>(BoxSlot))
{
VerticalSlot->SetSize(FSlateChildSize(ESlateSizeRule::Fill));
}
}
// Case retrouvee ou reprise : ses anciennes lignes ne sont plus suivies, elles sont retirees ici.
Box->ClearChildren();
RewardLines.Reset();
RewardBox = Box;
if (!bDisplayTargetLogged)
{
bDisplayTargetLogged = true;
UE_LOG(LogLifePlay, Log, TEXT("Bonus : affichage dans EquipMenu (classe %s, %d enfants)"),
*Equip->GetClass()->GetName(), Equip->GetChildrenCount());
}
return Box;
}
void ULPBonusRewardSubsystem::ExpireRewardLines()
{
// Q-I2-2 : chaque ligne est retiree 20 s apres son affichage. Echeance en temps reel (FPlatformTime::Seconds, meme
// horloge que le tempo de 0,2 s), relue a chaque passage du ticker existant (0,1 s) : retrait entre 20,0 et 20,1 s.
if (RewardLines.Num() == 0)
{
return;
}
const double Now = FPlatformTime::Seconds();
for (int32 Index = RewardLines.Num() - 1; Index >= 0; --Index)
{
if (Now >= RewardLines[Index].ExpireAt || !RewardLines[Index].Line.IsValid())
{
LPBonusReward::RemoveLine(RewardLines[Index]);
RewardLines.RemoveAt(Index);
}
}
}
void ULPBonusRewardSubsystem::ClearRewardDisplay(bool bRemoveWidgets)
{
// Garde 3 : fin de scene (Poll, SexOn faux) : lignes retirees de l'ecran et minuteries videes ; Deinitialize :
// minuteries videes seulement.
if (RewardLines.Num() == 0)
{
return;
}
if (bRemoveWidgets)
{
for (const FLPBonusRewardLine& Entry : RewardLines)
{
LPBonusReward::RemoveLine(Entry);
}
}
RewardLines.Reset();
}
bool ULPBonusRewardSubsystem::AttractionAllowed(UObject* GI, UObject* Person)
{
// Condition de l'auteur (SPEC 1.1 E6c) : NON Person.isPlayer ET GI.IsInterestedIn(Person, GI.PlayerCharacter) ET
// GI.SexPersons contient GI.PlayerCharacter. Propriete ou fonction absente : avertissement, cle refusee.
const FBoolProperty* IsPlayerProp = CastField<FBoolProperty>(LPBonusReward::FindProp(Person, TEXT("isPlayer")));
if (!IsPlayerProp)
{
WarnOnce(TEXT("Person.isPlayer"), FString::Printf(TEXT("pas de propriete booleenne isPlayer dans %s, cle attractiontoplayer refusee"),
*Person->GetClass()->GetName()));
return false;
}
if (IsPlayerProp->GetPropertyValue_InContainer(Person))
{
return false;
}
UObject* Player = nullptr;
if (!LPBonusReward::ReadObject(GI, TEXT("PlayerCharacter"), Player))
{
WarnOnce(TEXT("GI.PlayerCharacter"), FString::Printf(TEXT("pas de propriete objet PlayerCharacter dans %s, cle attractiontoplayer refusee"),
*GI->GetClass()->GetName()));
return false;
}
if (!IsValid(Player))
{
return false;
}
TArray<UObject*> Persons;
if (!LPBonusReward::ReadObjects(GI, TEXT("SexPersons"), Persons) || !Persons.Contains(Player))
{
return false;
}
bool bInterested = false;
if (!IsInterestedIn(GI, Person, Player, bInterested))
{
return false;
}
return bInterested;
}
bool ULPBonusRewardSubsystem::IsInterestedIn(UObject* GI, UObject* WhoIsInterested, UObject* WhoInterests, bool& bOutInterested)
{
// GI.IsInterestedIn(WhoIsInterested, WhoInterests) -> Interested (L6f_dump_GI.txt:23421-23424, :23442-23443).
bOutInterested = false;
UFunction* Function = GI->FindFunction(FName(TEXT("IsInterestedIn")));
if (!Function)
{
WarnOnce(TEXT("GI.IsInterestedIn"), FString::Printf(TEXT("fonction IsInterestedIn introuvable dans %s, cle attractiontoplayer refusee"),
*GI->GetClass()->GetName()));
return false;
}
const FObjectPropertyBase* WhoIsParm = CastField<FObjectPropertyBase>(LPBonusReward::FindParm(Function, TEXT("WhoIsInterested")));
const FObjectPropertyBase* WhoParm = CastField<FObjectPropertyBase>(LPBonusReward::FindParm(Function, TEXT("WhoInterests")));
const FBoolProperty* ResultParm = CastField<FBoolProperty>(LPBonusReward::FindParm(Function, TEXT("Interested")));
if (!WhoIsParm || !WhoParm || !ResultParm || !ResultParm->HasAnyPropertyFlags(CPF_OutParm)
|| !WhoIsParm->PropertyClass || !WhoParm->PropertyClass)
{
WarnOnce(TEXT("GI.IsInterestedIn.Parms"), FString::Printf(TEXT("signature inattendue de IsInterestedIn : %s ; cle attractiontoplayer refusee"),
*LPBonusReward::DescribeParms(Function)));
return false;
}
if (!WhoIsInterested->IsA(WhoIsParm->PropertyClass) || !WhoInterests->IsA(WhoParm->PropertyClass))
{
WarnOnce(TEXT("GI.IsInterestedIn.Types"), FString::Printf(TEXT("IsInterestedIn attend %s et %s, recu %s et %s ; cle attractiontoplayer refusee"),
*WhoIsParm->PropertyClass->GetName(), *WhoParm->PropertyClass->GetName(),
*WhoIsInterested->GetClass()->GetName(), *WhoInterests->GetClass()->GetName()));
return false;
}
const int32 ParmsSize = Function->ParmsSize;
const int32 Alignment = Function->GetMinAlignment();
uint8* Memory = static_cast<uint8*>(FMemory_Alloca_Aligned(ParmsSize, Alignment));
LPBonusReward::FScopedParms Parms(Function, Memory);
WhoIsParm->SetObjectPropertyValue_InContainer(Memory, WhoIsInterested);
WhoParm->SetObjectPropertyValue_InContainer(Memory, WhoInterests);
GI->ProcessEvent(Function, Memory);
bOutInterested = ResultParm->GetPropertyValue_InContainer(Memory);
return true;
}
bool ULPBonusRewardSubsystem::QueryStat(UObject* Person, const FString& Stat, double& OutValue)
{
// Person.QueryStat(Stat, WithoutBuff, RNG) -> Value, Bonus (L6f_dump_Person.txt:20-29) ; appel de l'auteur :
// QueryStat(Stat, false, false).Value (SPEC 1.1 E6d).
OutValue = 0.0;
UFunction* Function = Person->FindFunction(FName(TEXT("QueryStat")));
if (!Function)
{
WarnOnce(TEXT("Person.QueryStat"), FString::Printf(TEXT("fonction QueryStat introuvable dans %s, recompense abandonnee"),
*Person->GetClass()->GetName()));
return false;
}
const FStrProperty* StatParm = CastField<FStrProperty>(LPBonusReward::FindParm(Function, TEXT("Stat")));
const FProperty* ValueParm = LPBonusReward::FindParm(Function, TEXT("Value"));
const bool bSignature = StatParm && LPBonusReward::IsReal(ValueParm) && ValueParm->HasAnyPropertyFlags(CPF_OutParm)
&& CastField<FBoolProperty>(LPBonusReward::FindParm(Function, TEXT("WithoutBuff")))
&& CastField<FBoolProperty>(LPBonusReward::FindParm(Function, TEXT("RNG")));
if (!bSignature)
{
WarnOnce(TEXT("Person.QueryStat.Parms"), FString::Printf(TEXT("signature inattendue de QueryStat : %s ; recompense abandonnee"),
*LPBonusReward::DescribeParms(Function)));
return false;
}
const int32 ParmsSize = Function->ParmsSize;
const int32 Alignment = Function->GetMinAlignment();
uint8* Memory = static_cast<uint8*>(FMemory_Alloca_Aligned(ParmsSize, Alignment));
LPBonusReward::FScopedParms Parms(Function, Memory);
StatParm->SetPropertyValue_InContainer(Memory, Stat);
LPBonusReward::SetBoolParm(Function, Memory, TEXT("WithoutBuff"), false);
LPBonusReward::SetBoolParm(Function, Memory, TEXT("RNG"), false);
Person->ProcessEvent(Function, Memory);
OutValue = LPBonusReward::ReadReal(ValueParm, Memory);
return true;
}
bool ULPBonusRewardSubsystem::ModifyStat(UObject* Person, const FString& Stat, double Change)
{
// Person.ModifyStat(Stat, Change, DifficultyAdjusted, NoDisplay) -> Value (L6f_dump_Person.txt:561-570) ; appel de
// l'auteur au clic : ModifyStat(Stat, Change, false, false) (SPEC 1.1 E7).
UFunction* Function = Person->FindFunction(FName(TEXT("ModifyStat")));
if (!Function)
{
WarnOnce(TEXT("Person.ModifyStat"), FString::Printf(TEXT("fonction ModifyStat introuvable dans %s, recompense abandonnee"),
*Person->GetClass()->GetName()));
return false;
}
const FStrProperty* StatParm = CastField<FStrProperty>(LPBonusReward::FindParm(Function, TEXT("Stat")));
const FProperty* ChangeParm = LPBonusReward::FindParm(Function, TEXT("Change"));
const bool bSignature = StatParm && LPBonusReward::IsReal(ChangeParm) && !ChangeParm->HasAnyPropertyFlags(CPF_OutParm)
&& CastField<FBoolProperty>(LPBonusReward::FindParm(Function, TEXT("DifficultyAdjusted")))
&& CastField<FBoolProperty>(LPBonusReward::FindParm(Function, TEXT("NoDisplay")));
if (!bSignature)
{
WarnOnce(TEXT("Person.ModifyStat.Parms"), FString::Printf(TEXT("signature inattendue de ModifyStat : %s ; recompense abandonnee"),
*LPBonusReward::DescribeParms(Function)));
return false;
}
const int32 ParmsSize = Function->ParmsSize;
const int32 Alignment = Function->GetMinAlignment();
uint8* Memory = static_cast<uint8*>(FMemory_Alloca_Aligned(ParmsSize, Alignment));
LPBonusReward::FScopedParms Parms(Function, Memory);
StatParm->SetPropertyValue_InContainer(Memory, Stat);
LPBonusReward::WriteReal(ChangeParm, Memory, Change);
LPBonusReward::SetBoolParm(Function, Memory, TEXT("DifficultyAdjusted"), false);
LPBonusReward::SetBoolParm(Function, Memory, TEXT("NoDisplay"), false);
Person->ProcessEvent(Function, Memory);
return true;
}
FString ULPBonusRewardSubsystem::PersonName(const UObject* Person)
{
// Name_First : texte chez l'auteur (SPEC 2.4) ; type non lu chez nous, texte et chaine acceptes.
const FProperty* Prop = LPBonusReward::FindProp(Person, TEXT("Name_First"));
FString Name;
if (const FTextProperty* AsText = CastField<FTextProperty>(Prop))
{
Name = AsText->GetPropertyValue_InContainer(Person).ToString();
}
else if (const FStrProperty* AsString = CastField<FStrProperty>(Prop))
{
Name = AsString->GetPropertyValue_InContainer(Person);
}
else if (Person)
{
WarnOnce(TEXT("Person.Name_First"), FString::Printf(TEXT("pas de propriete texte ou chaine Name_First dans %s, nom d'objet journalise a la place"),
*Person->GetClass()->GetName()));
}
return Name.IsEmpty() ? GetNameSafe(Person) : Name;
}
bool ULPBonusRewardSubsystem::FirstWarning(const FString& Key)
{
// D1 (STE-ORG5-V1-VAL.md) : la cause est testee avant que l'appelant construise son message.
if (Warned.Contains(Key))
{
return false;
}
Warned.Add(Key);
return true;
}
void ULPBonusRewardSubsystem::WarnOnce(const FString& Key, const FString& Message)
{
if (FirstWarning(Key))
{
UE_LOG(LogLifePlay, Warning, TEXT("Bonus : %s"), *Message);
}
}