Files
ProjectEleri/Source/ProjectEleriEditor/Private/ScheduleAssetRecheck.cpp

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