diff --git a/Content/BPs/Person.uasset b/Content/BPs/Person.uasset index 175b420..c9fbd40 100644 Binary files a/Content/BPs/Person.uasset and b/Content/BPs/Person.uasset differ diff --git a/ModSources/lpr_anim_quaternius/Module/CREDITS.txt b/ModSources/lpr_anim_quaternius/Module/CREDITS.txt new file mode 100644 index 0000000..7850c7e --- /dev/null +++ b/ModSources/lpr_anim_quaternius/Module/CREDITS.txt @@ -0,0 +1,2 @@ +Credits and licenses of the assets in Quaternius Animations (one line per asset: title, author, license, source URL, changes). +- Universal Animation Library by Quaternius, CC0 1.0, https://quaternius.com . Changes: retargeted to the Genesis 8 skeletons (Bridget, Michael), renamed qua__F / qua__M. diff --git a/ModSources/lpr_anim_quaternius/Module/lpr_anim_quaternius.lpmod b/ModSources/lpr_anim_quaternius/Module/lpr_anim_quaternius.lpmod new file mode 100644 index 0000000..36d938a --- /dev/null +++ b/ModSources/lpr_anim_quaternius/Module/lpr_anim_quaternius.lpmod @@ -0,0 +1,12 @@ +MODULE_UNIQUEID: lpr_anim_quaternius +MODULE_NAME: Quaternius Animations +MODULE_AUTHOR: LifePlay Revival (animations by Quaternius) +MODULE_LINK: https://github.com/edujiro5972/LifePlay-Revival +MODULE_DESCRIPTION: Free animation clips from the Quaternius Universal Animation Library (CC0), retargeted for LifePlay +MODULE_REQUIREMENTS: vin_Base +MODULE_VERSION: 1.0 +MODULE_CREATED: 2026-10-06 +MODULE_LICENSE: CC0 +MODULE_AUTHOR_LINK: https://quaternius.com +MODULE_PRICE: Free +MODULE_DEFAULT_ENABLED: true diff --git a/ModSources/lpr_anim_quaternius/lpr_anim_quaternius.uplugin b/ModSources/lpr_anim_quaternius/lpr_anim_quaternius.uplugin new file mode 100644 index 0000000..703291b --- /dev/null +++ b/ModSources/lpr_anim_quaternius/lpr_anim_quaternius.uplugin @@ -0,0 +1,13 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "Quaternius Animations", + "Description": "Free animation clips from the Quaternius Universal Animation Library (CC0), retargeted for LifePlay", + "Category": "LifePlay Mods", + "CreatedBy": "LifePlay Revival (animations by Quaternius)", + "CanContainContent": true, + "IsBetaVersion": false, + "Installed": false, + "CreatedByURL": "https://quaternius.com" +} diff --git a/Source/LifePlay/LPModDailies.cpp b/Source/LifePlay/LPModDailies.cpp new file mode 100644 index 0000000..7a44def --- /dev/null +++ b/Source/LifePlay/LPModDailies.cpp @@ -0,0 +1,174 @@ +// Lot AP5 - voir LPModDailies.h + +#include "LPModDailies.h" + +#include "Animation/AnimSequence.h" +#include "AssetRegistry/AssetRegistryModule.h" +#include "Engine/GameInstance.h" +#include "GameFramework/Actor.h" +#include "Interfaces/IPluginManager.h" +#include "LifePlay.h" + +namespace LPModDailiesImpl +{ + TSet GModKeys; + + const FName RoomPropertyName(TEXT("Room")); + const FName DailiesPropertyName(TEXT("Dailies")); + + /** _F / _M -> (coupure au dernier '_', comme Person::PlayDailyAnim apres AP5). */ + bool KeyOf(const FString& AssetName, FString& Key) + { + FString Suffix; + if (!AssetName.Split(TEXT("_"), &Key, &Suffix, ESearchCase::IgnoreCase, ESearchDir::FromEnd) || Key.IsEmpty()) + { + return false; + } + return Suffix.Equals(TEXT("F"), ESearchCase::IgnoreCase) || Suffix.Equals(TEXT("M"), ESearchCase::IgnoreCase); + } + + AActor* RoomOf(UGameInstance* GameInstance) + { + const FObjectPropertyBase* Prop = GameInstance + ? CastField(GameInstance->GetClass()->FindPropertyByName(RoomPropertyName)) : nullptr; + return Prop ? Cast(Prop->GetObjectPropertyValue_InContainer(GameInstance)) : nullptr; + } +} + +const TSet& ULPModDailiesSubsystem::ModKeys() +{ + return LPModDailiesImpl::GModKeys; +} + +void ULPModDailiesSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + LPModDailiesImpl::GModKeys.Reset(); + ModClips.Reset(); + + IAssetRegistry& Registry = FModuleManager::LoadModuleChecked(TEXT("AssetRegistry")).Get(); + for (const TSharedRef& Plugin : IPluginManager::Get().GetEnabledPluginsWithContent()) + { + if (Plugin->GetType() != EPluginType::Mod) + { + continue; + } + const FString Root = FString::Printf(TEXT("/%s/Anims"), *Plugin->GetName()); +#if WITH_EDITOR + // Editeur (-game compris) : le registre explore le contenu en arriere-plan et ne connait pas encore les + // clips du mod a ce stade. Jeu empaque : le registre du mod (AssetRegistry.bin) est deja charge. + Registry.ScanPathsSynchronous({ Root }, false); +#endif + TArray Assets; + Registry.GetAssetsByPath(FName(*Root), Assets, true); + int32 Count = 0; + for (const FAssetData& Asset : Assets) + { + FString Key; + if (Asset.AssetClassPath != UAnimSequence::StaticClass()->GetClassPathName() + || !LPModDailiesImpl::KeyOf(Asset.AssetName.ToString(), Key)) + { + continue; + } + ModClips.Add(TSoftObjectPtr(Asset.GetSoftObjectPath())); + LPModDailiesImpl::GModKeys.Add(Key.ToLower()); + ++Count; + } + if (Count > 0) + { + UE_LOG(LogLifePlay, Log, TEXT("LPModDailies : %s, %d clip(s) sous %s"), *Plugin->GetName(), Count, *Root); + } + } + if (ModClips.Num() == 0) + { + return; + } + UE_LOG(LogLifePlay, Log, TEXT("LPModDailies : %d cle(s) de mod : %s"), LPModDailiesImpl::GModKeys.Num(), + *FString::Join(LPModDailiesImpl::GModKeys.Array(), TEXT(", "))); + TickHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateUObject(this, &ULPModDailiesSubsystem::Tick)); +} + +void ULPModDailiesSubsystem::Deinitialize() +{ + FTSTicker::GetCoreTicker().RemoveTicker(TickHandle); + LPModDailiesImpl::GModKeys.Reset(); + Super::Deinitialize(); +} + +bool ULPModDailiesSubsystem::Tick(float DeltaTime) +{ + AActor* Room = LPModDailiesImpl::RoomOf(GetGameInstance()); + if (!IsValid(Room) || DoneRoom.Get() == Room) + { + return true; + } + DoneRoom = Room; + + const FArrayProperty* Prop = CastField(Room->GetClass()->FindPropertyByName(LPModDailiesImpl::DailiesPropertyName)); + const FObjectPropertyBase* Inner = Prop ? CastField(Prop->Inner) : nullptr; + if (!Inner) + { + UE_LOG(LogLifePlay, Error, TEXT("LPModDailies : %s sans tableau Dailies, clips des mods non ajoutes."), *Room->GetName()); + return true; + } + + if (Loaded.Num() == 0) + { + for (const TSoftObjectPtr& Clip : ModClips) + { + if (UAnimSequence* Anim = Clip.LoadSynchronous()) + { + Loaded.Add(Anim); + } + else + { + UE_LOG(LogLifePlay, Error, TEXT("LPModDailies : %s introuvable."), *Clip.ToString()); + } + } + } + + FScriptArrayHelper Array(Prop, Prop->ContainerPtrToValuePtr(Room)); + TSet Present; + TSet BaseKeys; + for (int32 Index = 0; Index < Array.Num(); ++Index) + { + if (const UObject* Existing = Inner->GetObjectPropertyValue(Array.GetRawPtr(Index))) + { + Present.Add(Existing); + FString Key; + if (LPModDailiesImpl::KeyOf(Existing->GetName(), Key)) + { + BaseKeys.Add(Key.ToLower()); + } + } + } + int32 Added = 0; + for (UAnimSequence* Anim : Loaded) + { + if (Present.Contains(Anim)) + { + continue; + } + FString Key; + if (LPModDailiesImpl::KeyOf(Anim->GetName(), Key) && BaseKeys.Contains(Key.ToLower())) + { + UE_LOG(LogLifePlay, Warning, TEXT("LPModDailies : %s ignore, la cle '%s' existe deja dans le jeu."), + *Anim->GetPathName(), *Key); + continue; + } + const int32 Index = Array.AddValue(); + Inner->SetObjectPropertyValue(Array.GetRawPtr(Index), Anim); + ++Added; + } + UE_LOG(LogLifePlay, Log, TEXT("LPModDailies : %d clip(s) ajoute(s) a %s.Dailies (%d au total)."), + Added, *Room->GetName(), Array.Num()); + return true; +} + +bool ULPModDailiesLibrary::IsModDaily(const FString& Key) +{ + // Appele seulement pour une cle absente de l'aiguillage de PlayDaily : rare, journalise pour l'essai en jeu. + const bool bMod = LPModDailiesImpl::GModKeys.Contains(Key.ToLower()); + UE_LOG(LogLifePlay, Log, TEXT("LPModDailies : PlayDaily(%s) -> %s"), *Key, bMod ? TEXT("cle de mod") : TEXT("cle inconnue")); + return bMod; +} diff --git a/Source/LifePlay/LPModDailies.h b/Source/LifePlay/LPModDailies.h new file mode 100644 index 0000000..7e7d410 --- /dev/null +++ b/Source/LifePlay/LPModDailies.h @@ -0,0 +1,59 @@ +// Lot AP5 (Docs/Migration/20_Chaine_Animations.md) : animations de scenario apportees par un mod. +// +// Le jeu resout ANIMATION: / Actor.animate() dans Person::PlayDaily : un aiguillage de cles ecrites en +// dur, puis une recherche par nom dans le tableau Room.Dailies (34 clips fixes de /Game/Heavy/New3/Anims/Daily), +// suffixe _M pour un homme, autre suffixe pour une femme (releve AP5, etat/L6f_dump_Person.txt). +// +// Ce sous-systeme rend appelables les clips d'un mod : +// - au demarrage, il releve dans le registre d'assets les UAnimSequence des plugins de type Mod sous //Anims, +// nommes _F / _M ; +// - des que GI.Room existe (et a chaque nouvel acteur Room), il les ajoute a la fin de Room.Dailies : une cle du jeu +// reste donc toujours servie par le clip du jeu ; +// - ULPModDailiesLibrary::IsModDaily() indique a Person::PlayDaily (branchement pose par -run=LPPatchDailies) +// qu'une cle inconnue de l'aiguillage vient d'un mod et doit suivre le chemin generique (celui de dance). +// Sans mod, il ne fait rien. + +#pragma once + +#include "CoreMinimal.h" +#include "Containers/Ticker.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "Subsystems/GameInstanceSubsystem.h" +#include "LPModDailies.generated.h" + +class UAnimSequence; + +UCLASS() +class LIFEPLAY_API ULPModDailiesSubsystem : public UGameInstanceSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** Cles apportees par les mods (minuscules), relevees au demarrage. */ + static const TSet& ModKeys(); + +private: + bool Tick(float DeltaTime); + + FTSTicker::FDelegateHandle TickHandle; + /** Clips des mods, charges au premier ajout a Room.Dailies. */ + TArray> ModClips; + UPROPERTY() + TArray> Loaded; + /** Dernier acteur Room complete. */ + TWeakObjectPtr DoneRoom; +}; + +UCLASS() +class LIFEPLAY_API ULPModDailiesLibrary : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + /** Vrai si est une cle d'animation apportee par un mod (sans tenir compte de la casse). */ + UFUNCTION(BlueprintPure, Category = "LifePlay|Animation") + static bool IsModDaily(const FString& Key); +}; diff --git a/Source/LifePlayEditorTools/LPPatchDailiesCommandlet.cpp b/Source/LifePlayEditorTools/LPPatchDailiesCommandlet.cpp new file mode 100644 index 0000000..c7535c4 --- /dev/null +++ b/Source/LifePlayEditorTools/LPPatchDailiesCommandlet.cpp @@ -0,0 +1,241 @@ +// Lot AP5 - voir LPPatchDailiesCommandlet.h + +#include "LPPatchDailiesCommandlet.h" + +#include "EdGraph/EdGraph.h" +#include "EdGraph/EdGraphPin.h" +#include "EdGraphSchema_K2.h" +#include "Engine/Blueprint.h" +#include "K2Node_CallFunction.h" +#include "K2Node_IfThenElse.h" +#include "K2Node_MacroInstance.h" +#include "K2Node_SwitchString.h" +#include "K2Node_VariableGet.h" +#include "Kismet2/BlueprintEditorUtils.h" +#include "Kismet2/KismetEditorUtilities.h" +#include "LPModDailies.h" +#include "Misc/PackageName.h" +#include "UObject/Package.h" +#include "UObject/SavePackage.h" + +DEFINE_LOG_CATEGORY_STATIC(LogLPPatchDailies, Log, All); + +namespace +{ + const TCHAR* PersonPath = TEXT("/Game/BPs/Person.Person"); + const FName IsModDailyName(TEXT("IsModDaily")); + + UEdGraphNode* LinkedNode(const UEdGraphPin* Pin) + { + return Pin && Pin->LinkedTo.Num() == 1 && Pin->LinkedTo[0] ? Pin->LinkedTo[0]->GetOwningNode() : nullptr; + } + + bool Calls(const UEdGraphNode* Node, const FName Function) + { + const UK2Node_CallFunction* Call = Cast(Node); + return Call && Call->FunctionReference.GetMemberName() == Function; + } + + /** Aiguillage des cles de PlayDaily : SwitchString avec les cas martialart et na_sleep. */ + UK2Node_SwitchString* IsKeySwitch(UEdGraphNode* Node) + { + UK2Node_SwitchString* Switch = Cast(Node); + return Switch && Switch->FindPin(TEXT("martialart"), EGPD_Output) && Switch->FindPin(TEXT("na_sleep"), EGPD_Output) + ? Switch : nullptr; + } + + /** Split '_' de PlayDailyAnim : SourceString <- GetObjectName(Object <- element d'un ForEach sur Dailies). */ + UK2Node_CallFunction* IsDailiesSplit(UEdGraphNode* Node) + { + UK2Node_CallFunction* Split = Cast(Node); + const UEdGraphPin* InStr = Split && Calls(Split, TEXT("Split")) ? Split->FindPin(TEXT("InStr"), EGPD_Input) : nullptr; + if (!InStr || InStr->DefaultValue != TEXT("_") || InStr->LinkedTo.Num() > 0) + { + return nullptr; + } + UEdGraphNode* GetName = LinkedNode(Split->FindPin(TEXT("SourceString"), EGPD_Input)); + if (!Calls(GetName, TEXT("GetObjectName"))) + { + return nullptr; + } + const UEdGraphPin* Object = GetName->FindPin(TEXT("Object"), EGPD_Input); + const UEdGraphPin* Element = Object && Object->LinkedTo.Num() == 1 ? Object->LinkedTo[0] : nullptr; + const UK2Node_MacroInstance* Loop = Element ? Cast(Element->GetOwningNode()) : nullptr; + const UK2Node_VariableGet* Array = Loop ? Cast(LinkedNode(Loop->FindPin(TEXT("Array"), EGPD_Input))) : nullptr; + return Array && Array->VariableReference.GetMemberName() == TEXT("Dailies") ? Split : nullptr; + } + + /** Default deja branche par ce commandlet : Branch dont la condition vient d'IsModDaily. */ + bool IsOurBranch(const UEdGraphNode* Node) + { + const UK2Node_IfThenElse* Branch = Cast(Node); + return Branch && Calls(LinkedNode(Branch->GetConditionPin()), IsModDailyName); + } +} + +ULPPatchDailiesCommandlet::ULPPatchDailiesCommandlet() +{ + IsClient = false; + IsServer = false; + IsEditor = true; + LogToConsole = true; +} + +int32 ULPPatchDailiesCommandlet::Main(const FString& Params) +{ + const bool bDryRun = Params.Contains(TEXT("DryRun"), ESearchCase::IgnoreCase); + UE_LOG(LogLPPatchDailies, Warning, TEXT("=== Person : cles d'animation des mods (AP5)%s ==="), bDryRun ? TEXT(" [DRY RUN]") : TEXT("")); + + UFunction* IsModDaily = ULPModDailiesLibrary::StaticClass()->FindFunctionByName(IsModDailyName); + UBlueprint* Blueprint = LoadObject(nullptr, PersonPath); + if (!IsModDaily || !Blueprint) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("%s introuvable."), IsModDaily ? PersonPath : TEXT("ULPModDailiesLibrary::IsModDaily")); + return 1; + } + + TArray Graphs; + Blueprint->GetAllGraphs(Graphs); + TArray> Switches; + TArray Splits; + for (UEdGraph* Graph : Graphs) + { + for (UEdGraphNode* Node : Graph ? Graph->Nodes : TArray>()) + { + if (UK2Node_SwitchString* Switch = IsKeySwitch(Node)) + { + Switches.Emplace(Graph, Switch); + } + if (UK2Node_CallFunction* Split = IsDailiesSplit(Node)) + { + Splits.Add(Split); + } + } + } + if (Switches.Num() != 1 || Splits.Num() != 1) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("Attendu 1 aiguillage et 1 Split : %d et %d trouves. Rien n'est modifie."), + Switches.Num(), Splits.Num()); + return 1; + } + UEdGraph* Graph = Switches[0].Key; + UK2Node_SwitchString* Switch = Switches[0].Value; + UK2Node_CallFunction* Split = Splits[0]; + UE_LOG(LogLPPatchDailies, Warning, TEXT(" aiguillage : %s :: %s ; Split : %s"), *Graph->GetName(), *Switch->GetName(), *Split->GetName()); + + UEdGraphPin* Default = Switch->GetDefaultPin(); + UEdGraphPin* Selection = Switch->GetSelectionPin(); + UEdGraphPin* Generic = Switch->FindPin(TEXT("martialart"), EGPD_Output); + UEdGraphPin* SearchDir = Split->FindPin(TEXT("SearchDir"), EGPD_Input); + if (!Default || !Selection || Selection->LinkedTo.Num() != 1 || !Generic || Generic->LinkedTo.Num() != 1 || !SearchDir) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("Broches inattendues (Default, Selection, martialart ou SearchDir). Rien n'est modifie.")); + return 1; + } + UEdGraphPin* KeySource = Selection->LinkedTo[0]; + UEdGraphPin* GenericTarget = Generic->LinkedTo[0]; + + bool bSwitchDone = false; + if (Default->LinkedTo.Num() > 0) + { + if (Default->LinkedTo.Num() == 1 && IsOurBranch(Default->LinkedTo[0]->GetOwningNode())) + { + bSwitchDone = true; + UE_LOG(LogLPPatchDailies, Warning, TEXT(" 1. Default deja branche sur IsModDaily : laisse tel quel.")); + } + else + { + UE_LOG(LogLPPatchDailies, Error, TEXT(" 1. Default deja relie a autre chose (%s). Rien n'est modifie."), + *LinkedNode(Default)->GetName()); + return 1; + } + } + const bool bSplitDone = SearchDir->DefaultValue == TEXT("FromEnd"); + if (!bSplitDone && SearchDir->DefaultValue != TEXT("FromStart")) + { + UE_LOG(LogLPPatchDailies, Error, TEXT(" 2. SearchDir inattendu '%s'. Rien n'est modifie."), *SearchDir->DefaultValue); + return 1; + } + UE_LOG(LogLPPatchDailies, Warning, TEXT(" 1. Default -> IsModDaily(%s.%s) -> %s.%s : %s"), + *KeySource->GetOwningNode()->GetName(), *KeySource->PinName.ToString(), + *GenericTarget->GetOwningNode()->GetName(), *GenericTarget->PinName.ToString(), bSwitchDone ? TEXT("deja fait") : TEXT("a faire")); + UE_LOG(LogLPPatchDailies, Warning, TEXT(" 2. Split SearchDir FromStart -> FromEnd : %s"), bSplitDone ? TEXT("deja fait") : TEXT("a faire")); + if (bDryRun || (bSwitchDone && bSplitDone)) + { + UE_LOG(LogLPPatchDailies, Warning, TEXT("=== Termine sans modification. ===")); + return 0; + } + + const UEdGraphSchema_K2* Schema = GetDefault(); + Graph->Modify(); + bool bOk = true; + if (!bSwitchDone) + { + UK2Node_CallFunction* Test = NewObject(Graph); + Test->SetFromFunction(IsModDaily); + Test->NodePosX = Switch->NodePosX + 300; + Test->NodePosY = Switch->NodePosY - 200; + Test->CreateNewGuid(); + Test->PostPlacedNewNode(); + Test->AllocateDefaultPins(); + Graph->AddNode(Test, false, false); + + UK2Node_IfThenElse* Branch = NewObject(Graph); + Branch->NodePosX = Switch->NodePosX + 600; + Branch->NodePosY = Switch->NodePosY - 200; + Branch->CreateNewGuid(); + Branch->PostPlacedNewNode(); + Branch->AllocateDefaultPins(); + Graph->AddNode(Branch, false, false); + + UEdGraphPin* Key = Test->FindPin(TEXT("Key"), EGPD_Input); + bOk = Key && Schema->TryCreateConnection(KeySource, Key) + && Schema->TryCreateConnection(Test->GetReturnValuePin(), Branch->GetConditionPin()) + && Schema->TryCreateConnection(Default, Branch->GetExecPin()) + && Schema->TryCreateConnection(Branch->GetThenPin(), GenericTarget); + // Relecture : liaisons posees, et celles d'avant intactes. + bOk = bOk && IsOurBranch(LinkedNode(Default)) && Selection->LinkedTo.Contains(KeySource) + && Generic->LinkedTo.Contains(GenericTarget) && Branch->GetThenPin()->LinkedTo.Contains(GenericTarget) + && Branch->GetElsePin()->LinkedTo.Num() == 0; + } + if (bOk && !bSplitDone) + { + Split->Modify(); + Schema->TrySetDefaultValue(*SearchDir, TEXT("FromEnd")); + bOk = SearchDir->DefaultValue == TEXT("FromEnd"); + } + if (!bOk) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("Liaison refusee ou relecture en ecart : Person NON sauvegarde.")); + return 1; + } + + FBlueprintEditorUtils::MarkBlueprintAsStructurallyModified(Blueprint); + FKismetEditorUtilities::CompileBlueprint(Blueprint); + if (Blueprint->Status == BS_Error) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("Compilation de Person en erreur : NON sauvegarde.")); + return 1; + } + + UPackage* Package = Blueprint->GetOutermost(); + FString Filename; + if (!FPackageName::TryConvertLongPackageNameToFilename(Package->GetName(), Filename, FPackageName::GetAssetPackageExtension())) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("Chemin introuvable pour %s"), *Package->GetName()); + return 1; + } + ResetLoaders(Package); + FSavePackageArgs SaveArgs; + SaveArgs.TopLevelFlags = RF_Public | RF_Standalone; + SaveArgs.SaveFlags = SAVE_None; + SaveArgs.bWarnOfLongFilename = true; + SaveArgs.Error = GWarn; + if (!UPackage::SavePackage(Package, nullptr, *Filename, SaveArgs)) + { + UE_LOG(LogLPPatchDailies, Error, TEXT("ECHEC de sauvegarde de %s"), *Package->GetName()); + return 1; + } + UE_LOG(LogLPPatchDailies, Warning, TEXT("=== Person sauvegarde. ===")); + return 0; +} diff --git a/Source/LifePlayEditorTools/LPPatchDailiesCommandlet.h b/Source/LifePlayEditorTools/LPPatchDailiesCommandlet.h new file mode 100644 index 0000000..3c8e271 --- /dev/null +++ b/Source/LifePlayEditorTools/LPPatchDailiesCommandlet.h @@ -0,0 +1,32 @@ +// Lot AP5 (Docs/Migration/20_Chaine_Animations.md). Voir Source/LifePlay/LPModDailies.h. +// +// Deux retouches de Person (EventGraph), pour qu'une cle d'animation apportee par un mod soit jouee par PlayDaily : +// 1. la sortie Default de l'aiguillage des cles (SwitchString aux cas martialart, na_sleep...), vide jusqu'ici, +// passe par un Branch sur ULPModDailiesLibrary::IsModDaily(cle) et rejoint, si vrai, le chemin du cas +// martialart (chemin generique : debout, puis PlayDailyAnim). Une cle inconnue du jeu et des mods ne fait +// toujours rien ; +// 2. le Split '_' qui decoupe le nom des clips de Room.Dailies dans PlayDailyAnim cherche depuis la fin : une cle +// peut contenir '_' (qua_walk_loop_F -> qua_walk_loop / F). Sans effet sur les 17 cles du jeu (aucun '_'). +// +// Controles : chaque noeud vise doit etre trouve une seule fois ; Default libre (ou deja branche par ce commandlet) ; +// liaisons relues apres coup. Au moindre ecart, rien n'est sauvegarde (code 1). Rejouable : un correctif deja en +// place est signale et laisse tel quel. +// +// Usage : +// UnrealEditor-Cmd.exe -run=LPPatchDailies [-DryRun] + +#pragma once + +#include "CoreMinimal.h" +#include "Commandlets/Commandlet.h" +#include "LPPatchDailiesCommandlet.generated.h" + +UCLASS() +class ULPPatchDailiesCommandlet : public UCommandlet +{ + GENERATED_BODY() + +public: + ULPPatchDailiesCommandlet(); + virtual int32 Main(const FString& Params) override; +}; diff --git a/Tools/anim/DeliverAnims.ps1 b/Tools/anim/DeliverAnims.ps1 new file mode 100644 index 0000000..3d83a8c --- /dev/null +++ b/Tools/anim/DeliverAnims.ps1 @@ -0,0 +1,148 @@ +<# +.SYNOPSIS + Copie les clips d'animation convertis dans un mod d'assets, renommes a la convention du jeu (lot AP5). + +.DESCRIPTION + /Game/Quaternius/UAL/B_ -> //Anims/Female/qua__F ; M_ -> //Anims/Male/qua__M. + Le jeu appelle Actor.animate() et cherche _F et _M (cle = ce qui precede le dernier _). + 1. controle bloquant : rapport.json existe et valide = vrai (les a_revoir sont affiches, non bloquants) ; + 2. controles : editeur ferme, aucun autre plugin dans Mods\ ; + 3. table cle / sources / destinations calculee depuis Content\Quaternius\UAL\B_*.uasset et M_*.uasset ; + 4. -DryRun : affiche la table et s'arrete (rien n'est lance ni cree) ; + 5. sinon : cree ModSources\ s'il manque (jamais d'ecrasement), monte le mod, lance Tools\anim\DeliverAnims.py + dans UnrealEditor-Cmd (journal Saved\Logs\portage\AP5_DeliverAnims_.log), remet le mod en place, + controle la sortie (LIVRAISON copies 2n erreurs 0, n fichiers .uasset par sexe). Code de sortie 1 au moindre ecart. + +.EXAMPLE + .\Tools\anim\DeliverAnims.ps1 -DryRun + .\Tools\anim\DeliverAnims.ps1 +#> +param( + [string] $Name = 'lpr_anim_quaternius', + [string] $Source = '/Game/Quaternius/UAL', + [string] $Prefix = 'qua', + [string] $Exclude = 'A_TPose', + [string] $Report = '', + [switch] $DryRun +) + +$ErrorActionPreference = 'Stop' +$Root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +. (Join-Path $Root 'Tools\ModSource.ps1') +$Editor = 'D:\Game\Launcher\Engine\UE_5.5\Engine\Binaries\Win64\UnrealEditor-Cmd.exe' +if (-not $Report) { $Report = Join-Path $Root 'Saved\AnimPipeline\quaternius\rapport.json' } + +function New-IfMissing([string] $Path, [string] $Text) { + if (Test-Path $Path) { return } + New-Item -ItemType Directory -Force (Split-Path $Path -Parent) | Out-Null + [IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false)) + Write-Host " cree : $Path" +} + +try { + # 1. Verdict des controles qualite. + if (-not (Test-Path $Report)) { throw "rapport.json introuvable : $Report" } + $rapport = Get-Content $Report -Raw -Encoding utf8 | ConvertFrom-Json + if ($rapport.valide -ne $true) { + $echecs = @($rapport.echecs) -join '; ' + throw "Rapport non valide ($Report). Echecs : $echecs" + } + foreach ($r in @($rapport.a_revoir)) { Write-Warning "a revoir : $r" } + + # 2. Controles d'environnement. + $running = @(Get-Process UnrealEditor, UnrealEditor-Cmd -ErrorAction SilentlyContinue) + if ($running) { throw "UnrealEditor est ouvert (pid $($running[0].Id)) : le fermer avant la livraison" } + $stray = Get-StrayMods $Root + if ($stray) { throw "Autres plugins dans Mods\ ($($stray -join ', ')) : les remettre dans ModSources" } + + # 3. Table des cles. + if ($Source -notmatch '^/Game/(.+)$') { throw "-Source doit commencer par /Game/ (recu : $Source)" } + $srcDir = Join-Path $Root ('Content\' + ($Matches[1] -replace '/', '\')) + if (-not (Test-Path $srcDir)) { throw "Dossier source introuvable : $srcDir" } + $excluded = @($Exclude -split ',' | Where-Object { $_ }) + $fem = @{}; $mal = @{} + foreach ($f in Get-ChildItem $srcDir -File -Filter '*.uasset') { + $base = $f.BaseName + if ($base -like 'B_*') { $fem[$base.Substring(2)] = $Source.TrimEnd('/') + '/' + $base } + elseif ($base -like 'M_*') { $mal[$base.Substring(2)] = $Source.TrimEnd('/') + '/' + $base } + } + $clips = @(($fem.Keys + $mal.Keys) | Sort-Object -Unique | Where-Object { $_ -notin $excluded }) + $table = @() + $seen = @{} + foreach ($clip in $clips) { + if (-not $fem.ContainsKey($clip)) { throw "Clip $clip : source B_ absente" } + if (-not $mal.ContainsKey($clip)) { throw "Clip $clip : source M_ absente" } + $cle = ($Prefix + '_' + $clip).ToLowerInvariant() + if ($cle -notmatch '^[a-z0-9_]+$') { throw "Cle invalide (caracteres hors [a-z0-9_]) : $cle" } + if ($seen.ContainsKey($cle)) { throw "Cle en double : $cle ($clip et $($seen[$cle]))" } + $seen[$cle] = $clip + $table += [pscustomobject]@{ + Cle = $cle; SourceF = $fem[$clip]; SourceM = $mal[$clip] + AssetF = "/$Name/Anims/Female/${cle}_F"; AssetM = "/$Name/Anims/Male/${cle}_M" + } + } + if ($table.Count -eq 0) { throw "Aucun clip a livrer dans $srcDir" } + Write-Host "Table : $($table.Count) cle(s) pour /$Name/ (exclus : $($excluded -join ', '))" + $table | Select-Object -First 5 | ForEach-Object { Write-Host (" {0} : {1} -> {2} ; {3} -> {4}" -f $_.Cle, $_.SourceF, $_.AssetF, $_.SourceM, $_.AssetM) } + + if ($DryRun) { + Write-Host "A blanc : rien lance, rien cree" + exit 0 + } + + # 5. Sources du mod (jamais d'ecrasement). + $paths = Get-ModSourcePaths $Root $Name + $src = $paths.Source + $desc = 'Free animation clips from the Quaternius Universal Animation Library (CC0), retargeted for LifePlay' + $author = 'LifePlay Revival (animations by Quaternius)' + $uplugin = [ordered]@{ + FileVersion = 3; Version = 1; VersionName = '1.0'; FriendlyName = 'Quaternius Animations' + Description = $desc; Category = 'LifePlay Mods'; CreatedBy = $author + CanContainContent = $true; IsBetaVersion = $false; Installed = $false + CreatedByURL = 'https://quaternius.com' + } + New-IfMissing (Join-Path $src "$Name.uplugin") (($uplugin | ConvertTo-Json) + "`r`n") + $lpmod = @( + "MODULE_UNIQUEID: $Name", 'MODULE_NAME: Quaternius Animations', "MODULE_AUTHOR: $author", + 'MODULE_LINK: https://github.com/edujiro5972/LifePlay-Revival', "MODULE_DESCRIPTION: $desc", + 'MODULE_REQUIREMENTS: vin_Base', 'MODULE_VERSION: 1.0', "MODULE_CREATED: $(Get-Date -Format 'yyyy-MM-dd')", + 'MODULE_LICENSE: CC0', 'MODULE_AUTHOR_LINK: https://quaternius.com', 'MODULE_PRICE: Free', 'MODULE_DEFAULT_ENABLED: true') + New-IfMissing (Join-Path $src "Module\$Name.lpmod") (($lpmod -join "`r`n") + "`r`n") + $credits = "Credits and licenses of the assets in Quaternius Animations (one line per asset: title, author, license, source URL, changes).`r`n" + + "- Universal Animation Library by Quaternius, CC0 1.0, https://quaternius.com . Changes: retargeted to the Genesis 8 skeletons (Bridget, Michael), renamed qua__F / qua__M.`r`n" + New-IfMissing (Join-Path $src 'Module\CREDITS.txt') $credits + New-Item -ItemType Directory -Force (Join-Path $src 'Content') | Out-Null + + # Lancement d'Unreal, mod monte. + $logDir = Join-Path $Root 'Saved\Logs\portage' + New-Item -ItemType Directory -Force $logDir | Out-Null + $log = Join-Path $logDir ('AP5_DeliverAnims_{0}.log' -f (Get-Date -Format 'yyyyMMdd-HHmmss')) + $py = (Join-Path $Root 'Tools\anim\DeliverAnims.py') -replace '\\', '/' + $mounted = Mount-ModSource $Root $Name + try { + $script = "-script=$py mod=$Name src=$Source prefix=$Prefix exclude=$Exclude" + Write-Host "Livraison dans /$Name/ (journal : $log)" + & $Editor (Join-Path $Root 'LifePlay.uproject') -run=pythonscript $script -unattended -nopause -nosplash -stdout -FullStdOutLogOutput *> $log + } + finally { + Dismount-ModSource $Root $Name + } + + # Controle de sortie. + $n = $table.Count + if (-not (Select-String -Path $log -Pattern "LIVRAISON copies $($n * 2) erreurs 0\b" -Quiet)) { + $last = Select-String -Path $log -Pattern 'LIVRAISON' | Select-Object -Last 3 | ForEach-Object { $_.Line } + throw "Ligne 'LIVRAISON copies $($n * 2) erreurs 0' absente : $($last -join ' | ') (voir $log)" + } + foreach ($sexe in 'Female', 'Male') { + $dir = Join-Path $src "Content\Anims\$sexe" + $count = @(Get-ChildItem $dir -Filter '*.uasset' -ErrorAction SilentlyContinue).Count + if ($count -ne $n) { throw "$dir : $count fichier(s) .uasset pour $n cle(s)" } + } + Write-Host "Livraison terminee : $n cle(s), $($n * 2) copies" + exit 0 +} +catch { + Write-Host "ECHEC : $($_.Exception.Message)" -ForegroundColor Red + exit 1 +} diff --git a/Tools/anim/DeliverAnims.py b/Tools/anim/DeliverAnims.py new file mode 100644 index 0000000..ade1b31 --- /dev/null +++ b/Tools/anim/DeliverAnims.py @@ -0,0 +1,127 @@ +# Livraison des clips d'animation convertis dans un mod d'assets (lot AP5). +# Lancement : +# UnrealEditor-Cmd.exe -run=pythonscript "-script=/Tools/anim/DeliverAnims.py mod= src=/Game/Quaternius/UAL prefix=qua exclude=A_TPose" +# Copie /Game/Quaternius/UAL/B_ -> //Anims/Female/__F +# /Game/Quaternius/UAL/M_ -> //Anims/Male/__M +# Les sources ne sont jamais deplacees ni renommees. Seules des copies du mod peuvent etre supprimees. +import json +import os +import re +import sys + +import unreal + +# Arguments : mod=... src=... prefix=... exclude=A,B (le script peut recevoir les arguments en un seul bloc) +args = {"mod": "lpr_anim_quaternius", "src": "/Game/Quaternius/UAL", "prefix": "qua", "exclude": "A_TPose"} +tokens = [] +for a in sys.argv[1:]: + tokens.extend(a.split()) +for t in tokens: + if "=" in t: + k, v = t.split("=", 1) + args[k.strip().lower()] = v.strip() + +MOD = args["mod"] +SRC = args["src"].rstrip("/") +PREFIX = args["prefix"] +EXCLUDE = set(x for x in args["exclude"].split(",") if x) +erreurs = [] +livraison = [] + + +def erreur(msg): + erreurs.append(msg) + unreal.log_error("LIVRAISON erreur : " + msg) + + +def lister(dossier, debut): + noms = set() + for p in unreal.EditorAssetLibrary.list_assets(dossier, recursive=False, include_folder=False): + nom = p.split("/")[-1].split(".")[0] + if nom.startswith(debut): + noms.add(nom[len(debut):]) + return noms + + +def infos(asset): + return asset.get_skeleton(), asset.get_editor_property("number_of_sampled_keys"), asset.get_editor_property("sequence_length") + + +# Garde-fou : on ne supprime et n'ecrit que dans le mod. +def sous_mod(chemin): + return chemin.startswith("/" + MOD + "/") + + +# En commandlet, le registre n'a pas encore explore le dossier du mod : sans ce scan, les copies d'une passe +# precedente sont ignorees par does_asset_exist et duplicate_asset echoue (AP5, deuxieme passe). +unreal.AssetRegistryHelpers.get_asset_registry().scan_paths_synchronous(["/" + MOD], True) + +clips = sorted((lister(SRC, "B_") & lister(SRC, "M_")) - EXCLUDE) +unreal.log("LIVRAISON %d clip(s) a livrer vers /%s/" % (len(clips), MOD)) + +for clip in clips: + cle = PREFIX + "_" + clip.lower() + if not re.match(r"^[a-z0-9_]+$", cle): + erreur("cle invalide : " + cle) + continue + sources = {"F": SRC + "/B_" + clip, "M": SRC + "/M_" + clip} + dests = {"F": "/%s/Anims/Female/%s_F" % (MOD, cle), "M": "/%s/Anims/Male/%s_M" % (MOD, cle)} + ref = {} + ok = True + for sexe in ("F", "M"): + s, d = sources[sexe], dests[sexe] + if not sous_mod(d): + erreur("destination hors du mod : " + d) + ok = False + continue + asrc = unreal.EditorAssetLibrary.load_asset(s) + if not isinstance(asrc, unreal.AnimSequence): + erreur("source absente ou pas une AnimSequence : " + s) + ok = False + continue + if unreal.EditorAssetLibrary.does_asset_exist(d): + if not unreal.EditorAssetLibrary.delete_asset(d): + erreur("suppression de la copie precedente impossible : " + d) + ok = False + continue + copie = unreal.EditorAssetLibrary.duplicate_asset(s, d) + if copie is None: + erreur("copie impossible : %s -> %s" % (s, d)) + ok = False + continue + if not unreal.EditorAssetLibrary.save_asset(d, only_if_is_dirty=False): + erreur("enregistrement impossible : " + d) + ok = False + continue + # Controles : existe, AnimSequence, meme squelette, meme nombre d'images, meme duree. + acopie = unreal.EditorAssetLibrary.load_asset(d) + if not unreal.EditorAssetLibrary.does_asset_exist(d) or not isinstance(acopie, unreal.AnimSequence): + erreur("copie absente ou pas une AnimSequence : " + d) + ok = False + continue + sk0, n0, l0 = infos(asrc) + sk1, n1, l1 = infos(acopie) + if sk0 != sk1: + erreur("squelette different : " + d) + ok = False + if n0 != n1: + erreur("nombre d'images different (%s / %s) : %s" % (n0, n1, d)) + ok = False + if abs(l0 - l1) > 1e-4: + erreur("duree differente (%s / %s) : %s" % (l0, l1, d)) + ok = False + ref[sexe] = (n1, l1) + if ok: + livraison.append({ + "cle": cle, + "source_F": sources["F"], "source_M": sources["M"], + "asset_F": dests["F"], "asset_M": dests["M"], + "images": ref["F"][0], "duree_s": ref["F"][1], + }) + +sortie = os.path.join(unreal.Paths.project_saved_dir(), "AnimPipeline", "quaternius", "livraison.json") +os.makedirs(os.path.dirname(sortie), exist_ok=True) +with open(sortie, "w", encoding="utf-8") as f: + json.dump(livraison, f, indent=2, ensure_ascii=False) + +unreal.log("LIVRAISON copies %d erreurs %d" % (len(livraison) * 2, len(erreurs)))