267 lines
10 KiB
C++
267 lines
10 KiB
C++
|
|
// Fill out your copyright notice in the Description page of Project Settings.
|
||
|
|
|
||
|
|
#include "ScheduleAssetRecheck.h"
|
||
|
|
|
||
|
|
#include "AssetRegistry/AssetRegistryModule.h"
|
||
|
|
#include "AssetRegistry/IAssetRegistry.h"
|
||
|
|
#include "Editor.h"
|
||
|
|
#include "EngineUtils.h"
|
||
|
|
#include "FileHelpers.h"
|
||
|
|
#include "Framework/Notifications/NotificationManager.h"
|
||
|
|
#include "Modules/ModuleManager.h"
|
||
|
|
#include "Widgets/Notifications/SNotificationList.h"
|
||
|
|
|
||
|
|
#include "ProjectEleri/Ai/NpcScheduleAsset.h"
|
||
|
|
#include "ProjectEleri/GameObjects/EleriStandPoint.h"
|
||
|
|
#include "ProjectEleri/Public/Interface/ActorPersistentIdInterface.h"
|
||
|
|
|
||
|
|
#define LOCTEXT_NAMESPACE "ScheduleAssetRecheck"
|
||
|
|
|
||
|
|
namespace ScheduleAssetRecheck
|
||
|
|
{
|
||
|
|
namespace
|
||
|
|
{
|
||
|
|
/** The id a schedule would name this actor by. Implementing the interface is the whole of the
|
||
|
|
* "can this actor be an anchor" rule, because the interface is what carries the id a schedule
|
||
|
|
* stores, so this looks for anchors exactly the way the game and the details panel do rather
|
||
|
|
* than through a notion of furniture of its own.
|
||
|
|
*
|
||
|
|
* Written out rather than shared with FNpcScheduleActionCustomization, which answers the same
|
||
|
|
* question about the details panel's selection and keeps the answer to itself. It is one line
|
||
|
|
* of casting, and duplicating it is the smaller fault next to a picker that only ever sees one
|
||
|
|
* action at a time having to grow a second responsibility. */
|
||
|
|
FGuid GetAnchorId(AActor* Actor)
|
||
|
|
{
|
||
|
|
if (Actor == nullptr)
|
||
|
|
{
|
||
|
|
return FGuid();
|
||
|
|
}
|
||
|
|
|
||
|
|
const IActorPersistentIdInterface* Identified = Cast<IActorPersistentIdInterface>(Actor);
|
||
|
|
return Identified != nullptr ? Identified->GetPersistentId() : FGuid();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Every anchor the open level can answer for, by the id a schedule names it with.
|
||
|
|
*
|
||
|
|
* One walk for the whole sweep rather than a walk per action: the levels this runs on hold a
|
||
|
|
* project's worth of schedules, and asking the level once per action would make the sweep
|
||
|
|
* quadratic in the size of the level for no reason, since the answer cannot change under it.
|
||
|
|
*
|
||
|
|
* The first actor to claim an id keeps it, which is the same answer a game arrives at, both
|
||
|
|
* sides walking the level and taking what they meet first. A second claimant is logged while
|
||
|
|
* somebody can still do something about it, because a schedule naming that id is going to
|
||
|
|
* follow one of the two and neither of them gets a say in which. */
|
||
|
|
void CollectAnchorsById(UWorld& World, TMap<FGuid, AActor*>& OutAnchorsById)
|
||
|
|
{
|
||
|
|
for (TActorIterator<AActor> It(&World); It; ++It)
|
||
|
|
{
|
||
|
|
AActor* Actor = *It;
|
||
|
|
const FGuid AnchorId = GetAnchorId(Actor);
|
||
|
|
if (!AnchorId.IsValid())
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
AActor*& Claimant = OutAnchorsById.FindOrAdd(AnchorId);
|
||
|
|
if (Claimant == nullptr)
|
||
|
|
{
|
||
|
|
Claimant = Actor;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
UE_LOG(LogTemp, Warning,
|
||
|
|
TEXT("NPC schedule anchor %s is claimed by both %s and %s, and a schedule naming it will follow %s."),
|
||
|
|
*AnchorId.ToString(EGuidFormats::Digits), *Claimant->GetName(), *Actor->GetName(), *Claimant->GetName());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
FResult RecheckAllScheduleAssets()
|
||
|
|
{
|
||
|
|
FResult Result;
|
||
|
|
|
||
|
|
// One place that says how it went, so that the answer reads the same whether the sweep found
|
||
|
|
// nothing to do, did its job, or could not finish it.
|
||
|
|
const auto Notify = [](const FText& Summary, const FText& Detail, SNotificationItem::ECompletionState State)
|
||
|
|
{
|
||
|
|
FNotificationInfo Info(Summary);
|
||
|
|
if (!Detail.IsEmpty())
|
||
|
|
{
|
||
|
|
Info.SubText = Detail;
|
||
|
|
}
|
||
|
|
|
||
|
|
Info.bUseSuccessFailIcons = true;
|
||
|
|
Info.ExpireDuration = 5.0f;
|
||
|
|
|
||
|
|
if (TSharedPtr<SNotificationItem> Item = FSlateNotificationManager::Get().AddNotification(Info))
|
||
|
|
{
|
||
|
|
Item->SetCompletionState(State);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// The editor world is the only one this can read, both because it is the only one there is
|
||
|
|
// while nothing is playing and because what is being compared is authored data against the
|
||
|
|
// level it was authored in. With no level there is nothing to compare against at all, and
|
||
|
|
// writing the schedules from nowhere would be inventing a place rather than recording one.
|
||
|
|
UWorld* World = GEditor != nullptr ? GEditor->GetEditorWorldContext().World() : nullptr;
|
||
|
|
if (World == nullptr)
|
||
|
|
{
|
||
|
|
Notify(LOCTEXT("NoEditorWorld", "Recheck Schedule Assets: no level is open."), FText(), SNotificationItem::CS_Fail);
|
||
|
|
return Result;
|
||
|
|
}
|
||
|
|
|
||
|
|
TMap<FGuid, AActor*> AnchorsById;
|
||
|
|
CollectAnchorsById(*World, AnchorsById);
|
||
|
|
|
||
|
|
IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
|
||
|
|
|
||
|
|
TArray<FAssetData> ScheduleAssets;
|
||
|
|
AssetRegistry.GetAssetsByClass(UNpcScheduleAsset::StaticClass()->GetClassPathName(), ScheduleAssets, /*bSearchSubClasses=*/true);
|
||
|
|
|
||
|
|
// Only the packages that were actually written are gathered, so that a sweep which found
|
||
|
|
// nothing to do leaves the disk alone, and one that found several saves them in a single step
|
||
|
|
// rather than one stamp of the clock per asset.
|
||
|
|
TArray<UPackage*> PackagesToSave;
|
||
|
|
|
||
|
|
for (const FAssetData& AssetData : ScheduleAssets)
|
||
|
|
{
|
||
|
|
// The live object rather than the registry's tags, because what is being read is UPROPERTY
|
||
|
|
// data the registry does not index, and because what is being written has to go through the
|
||
|
|
// object the registry would otherwise be left holding a stale idea of.
|
||
|
|
UNpcScheduleAsset* Schedule = Cast<UNpcScheduleAsset>(AssetData.GetAsset());
|
||
|
|
if (Schedule == nullptr)
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
++Result.NumAssetsScanned;
|
||
|
|
|
||
|
|
bool bScheduleChanged = false;
|
||
|
|
|
||
|
|
for (FScheduledAction& Action : Schedule->ScheduledActions)
|
||
|
|
{
|
||
|
|
// An action with no anchor happens wherever the NPC already is. There is no anchor for
|
||
|
|
// it to have moved away from, so there is nothing here that can be stale.
|
||
|
|
if (!Action.UsableObjectGuid.IsValid())
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
++Result.NumAnchorsChecked;
|
||
|
|
|
||
|
|
AActor* const* Anchor = AnchorsById.Find(Action.UsableObjectGuid);
|
||
|
|
if (Anchor == nullptr)
|
||
|
|
{
|
||
|
|
// Not in this level: either the cell the furniture lives in is not loaded, or the
|
||
|
|
// schedule names something that is gone, and nothing here can tell those apart. The
|
||
|
|
// copy is left exactly as it was, the same way the details panel leaves it alone
|
||
|
|
// when it cannot find an anchor: it is the only thing anybody knows about where
|
||
|
|
// that anchor is, and this sweep has nothing better to put in its place.
|
||
|
|
++Result.NumAnchorsUnresolved;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const FTransform AnchorTransform = EleriStandPoint::ResolveAnchorTransform(*Anchor);
|
||
|
|
|
||
|
|
// The whole transform rather than the point on its own, because which way an anchor
|
||
|
|
// faces is part of where an NPC is put: a bed turned around is still walked to and
|
||
|
|
// still slept in, with the NPC doing it facing the wall. Comparing what the details
|
||
|
|
// panel would have written is also what makes a schedule this agrees with one the
|
||
|
|
// panel has nothing left to say about.
|
||
|
|
if (Action.bHasAnchorTransform && Action.AnchorTransform.Equals(AnchorTransform))
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
Action.AnchorTransform = AnchorTransform;
|
||
|
|
Action.bHasAnchorTransform = true;
|
||
|
|
++Result.NumAnchorsUpdated;
|
||
|
|
bScheduleChanged = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!bScheduleChanged)
|
||
|
|
{
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Notified and dirtied the way the details panel does it, so that an asset open in another
|
||
|
|
// tab follows the write instead of sitting there showing the transform that has just been
|
||
|
|
// replaced, and so that anything else watching the asset sees the change.
|
||
|
|
Schedule->PostEditChange();
|
||
|
|
Schedule->MarkPackageDirty();
|
||
|
|
|
||
|
|
if (UPackage* Package = Schedule->GetPackage())
|
||
|
|
{
|
||
|
|
PackagesToSave.Add(Package);
|
||
|
|
++Result.NumAssetsUpdated;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// One save for the whole sweep, over the packages that were written and no others. A false
|
||
|
|
// answer here is the one outcome that has to be said out loud rather than logged: the assets
|
||
|
|
// are then changed in memory and not on disk, and an author who takes this for a save loses
|
||
|
|
// the whole of it on the next reload.
|
||
|
|
const bool bSaved = PackagesToSave.Num() == 0
|
||
|
|
|| UEditorLoadingAndSavingUtils::SavePackages(PackagesToSave, /*bOnlyDirty=*/true);
|
||
|
|
|
||
|
|
UE_LOG(LogTemp, Log,
|
||
|
|
TEXT("Recheck Schedule Assets: %d asset(s) scanned, %d saved, %d anchor(s) checked, %d refreshed, %d not in this level."),
|
||
|
|
Result.NumAssetsScanned, Result.NumAssetsUpdated, Result.NumAnchorsChecked, Result.NumAnchorsUpdated, Result.NumAnchorsUnresolved);
|
||
|
|
|
||
|
|
// Said out loud rather than folded into the counts, because it is a question rather than an
|
||
|
|
// answer: an id nobody claims is either furniture in a cell that is not loaded, or a schedule
|
||
|
|
// naming something that no longer exists, and only the author can say which of the two it was
|
||
|
|
// meant to be. It goes beside every outcome below, since it is true of all of them.
|
||
|
|
const FText UnresolvedNote = Result.NumAnchorsUnresolved == 0
|
||
|
|
? FText()
|
||
|
|
: FText::Format(
|
||
|
|
LOCTEXT("UnresolvedAnchors", "{0} anchor(s) are not in this level, and their recorded places were left alone."),
|
||
|
|
FText::AsNumber(Result.NumAnchorsUnresolved));
|
||
|
|
|
||
|
|
// No assets at all is not a sweep that found everything clean, it is a sweep that looked in the
|
||
|
|
// wrong place, and it must not read as a pass.
|
||
|
|
if (Result.NumAssetsScanned == 0)
|
||
|
|
{
|
||
|
|
Notify(LOCTEXT("NoScheduleAssets", "Recheck Schedule Assets: no NPC schedule assets were found."), FText(), SNotificationItem::CS_None);
|
||
|
|
return Result;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!bSaved)
|
||
|
|
{
|
||
|
|
Notify(
|
||
|
|
FText::Format(
|
||
|
|
LOCTEXT("SaveFailed", "Recheck Schedule Assets: {0} asset(s) were updated but could not be saved."),
|
||
|
|
FText::AsNumber(Result.NumAssetsUpdated)),
|
||
|
|
UnresolvedNote,
|
||
|
|
SNotificationItem::CS_Fail);
|
||
|
|
return Result;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Result.NumAssetsUpdated == 0)
|
||
|
|
{
|
||
|
|
Notify(
|
||
|
|
FText::Format(
|
||
|
|
LOCTEXT("UpToDate", "Recheck Schedule Assets: all {0} asset(s) already match this level."),
|
||
|
|
FText::AsNumber(Result.NumAssetsScanned)),
|
||
|
|
UnresolvedNote,
|
||
|
|
SNotificationItem::CS_Success);
|
||
|
|
return Result;
|
||
|
|
}
|
||
|
|
|
||
|
|
Notify(
|
||
|
|
FText::Format(
|
||
|
|
LOCTEXT("Updated", "Recheck Schedule Assets: saved {0} of {1} asset(s), {2} anchor(s) refreshed."),
|
||
|
|
FText::AsNumber(Result.NumAssetsUpdated),
|
||
|
|
FText::AsNumber(Result.NumAssetsScanned),
|
||
|
|
FText::AsNumber(Result.NumAnchorsUpdated)),
|
||
|
|
UnresolvedNote,
|
||
|
|
SNotificationItem::CS_Success);
|
||
|
|
|
||
|
|
return Result;
|
||
|
|
}
|
||
|
|
|
||
|
|
}
|
||
|
|
|
||
|
|
#undef LOCTEXT_NAMESPACE
|
||
|
|
|