AP5 (restes) : animate() / LPModDailies, commandlet LPPatchDailies, Person, DeliverAnims, mod lpr_anim_quaternius

Phase AP5 (CHG-209).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
edujiro5972 2026-10-07 15:49:17 +02:00
parent e4b8752549
commit ad75b08d06
10 changed files with 808 additions and 0 deletions

Binary file not shown.

View file

@ -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_<clip>_F / qua_<clip>_M.

View file

@ -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

View file

@ -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"
}

View file

@ -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<FString> GModKeys;
const FName RoomPropertyName(TEXT("Room"));
const FName DailiesPropertyName(TEXT("Dailies"));
/** <cle>_F / <cle>_M -> <cle> (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<FObjectPropertyBase>(GameInstance->GetClass()->FindPropertyByName(RoomPropertyName)) : nullptr;
return Prop ? Cast<AActor>(Prop->GetObjectPropertyValue_InContainer(GameInstance)) : nullptr;
}
}
const TSet<FString>& ULPModDailiesSubsystem::ModKeys()
{
return LPModDailiesImpl::GModKeys;
}
void ULPModDailiesSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
LPModDailiesImpl::GModKeys.Reset();
ModClips.Reset();
IAssetRegistry& Registry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
for (const TSharedRef<IPlugin>& 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<FAssetData> 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<UAnimSequence>(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<FArrayProperty>(Room->GetClass()->FindPropertyByName(LPModDailiesImpl::DailiesPropertyName));
const FObjectPropertyBase* Inner = Prop ? CastField<FObjectPropertyBase>(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<UAnimSequence>& 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<void>(Room));
TSet<const UObject*> Present;
TSet<FString> 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;
}

View file

@ -0,0 +1,59 @@
// Lot AP5 (Docs/Migration/20_Chaine_Animations.md) : animations de scenario apportees par un mod.
//
// Le jeu resout ANIMATION: <cle> / Actor.animate(<cle>) 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 /<Mod>/Anims,
// nommes <cle>_F / <cle>_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(<cle>) 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<FString>& ModKeys();
private:
bool Tick(float DeltaTime);
FTSTicker::FDelegateHandle TickHandle;
/** Clips des mods, charges au premier ajout a Room.Dailies. */
TArray<TSoftObjectPtr<UAnimSequence>> ModClips;
UPROPERTY()
TArray<TObjectPtr<UAnimSequence>> Loaded;
/** Dernier acteur Room complete. */
TWeakObjectPtr<AActor> DoneRoom;
};
UCLASS()
class LIFEPLAY_API ULPModDailiesLibrary : public UBlueprintFunctionLibrary
{
GENERATED_BODY()
public:
/** Vrai si <Key> 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);
};

View file

@ -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<UK2Node_CallFunction>(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<UK2Node_SwitchString>(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<UK2Node_CallFunction>(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<UK2Node_MacroInstance>(Element->GetOwningNode()) : nullptr;
const UK2Node_VariableGet* Array = Loop ? Cast<UK2Node_VariableGet>(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<UK2Node_IfThenElse>(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<UBlueprint>(nullptr, PersonPath);
if (!IsModDaily || !Blueprint)
{
UE_LOG(LogLPPatchDailies, Error, TEXT("%s introuvable."), IsModDaily ? PersonPath : TEXT("ULPModDailiesLibrary::IsModDaily"));
return 1;
}
TArray<UEdGraph*> Graphs;
Blueprint->GetAllGraphs(Graphs);
TArray<TPair<UEdGraph*, UK2Node_SwitchString*>> Switches;
TArray<UK2Node_CallFunction*> Splits;
for (UEdGraph* Graph : Graphs)
{
for (UEdGraphNode* Node : Graph ? Graph->Nodes : TArray<TObjectPtr<UEdGraphNode>>())
{
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<UEdGraphSchema_K2>();
Graph->Modify();
bool bOk = true;
if (!bSwitchDone)
{
UK2Node_CallFunction* Test = NewObject<UK2Node_CallFunction>(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<UK2Node_IfThenElse>(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;
}

View file

@ -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 <projet> -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;
};

148
Tools/anim/DeliverAnims.ps1 Normal file
View file

@ -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_<Clip> -> /<Nom>/Anims/Female/qua_<clip>_F ; M_<Clip> -> /<Nom>/Anims/Male/qua_<clip>_M.
Le jeu appelle Actor.animate(<cle>) et cherche <cle>_F et <cle>_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\<Nom> s'il manque (jamais d'ecrasement), monte le mod, lance Tools\anim\DeliverAnims.py
dans UnrealEditor-Cmd (journal Saved\Logs\portage\AP5_DeliverAnims_<horodatage>.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_<clip>_F / qua_<clip>_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
}

127
Tools/anim/DeliverAnims.py Normal file
View file

@ -0,0 +1,127 @@
# Livraison des clips d'animation convertis dans un mod d'assets (lot AP5).
# Lancement :
# UnrealEditor-Cmd.exe <projet> -run=pythonscript "-script=<chemin>/Tools/anim/DeliverAnims.py mod=<Nom> src=/Game/Quaternius/UAL prefix=qua exclude=A_TPose"
# Copie /Game/Quaternius/UAL/B_<Clip> -> /<mod>/Anims/Female/<prefix>_<clip>_F
# /Game/Quaternius/UAL/M_<Clip> -> /<mod>/Anims/Male/<prefix>_<clip>_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)))