LifePlay-Revival/Source/LifePlay/LPCityMapsSubsystem.cpp
2026-10-22 21:10:39 +02:00

946 lines
30 KiB
C++

// Lot MAPDL1 (MAPDL1-SPEC-05 v3, etapes E1 et E5) - voir LPCityMapsSubsystem.h
#include "LPCityMapsSubsystem.h"
#include "Blueprint/UserWidget.h"
#include "Components/Button.h"
#include "Components/ScrollBox.h"
#include "Components/ScrollBoxSlot.h"
#include "Components/TextBlock.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "Engine/Engine.h"
#include "Engine/GameInstance.h"
#include "Engine/GameViewportClient.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformProcess.h"
#include "HAL/PlatformTime.h"
#include "LifePlay.h"
#include "LPAssetRegistryHelpers.h"
#include "LPCityMapsWindow.h"
#include "LPModGuard.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "ModdingExtraBPLibrary.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Styling/CoreStyle.h"
#include "UObject/StrProperty.h"
#include "UObject/UnrealType.h"
#include "UObject/UObjectIterator.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/SOverlay.h"
#include "Widgets/Text/STextBlock.h"
namespace LPCityMapsImpl
{
// Racines des cartes du jeu de base (meme forme que ULPAssetRegistryHelpers::ResolveCityPath).
const TCHAR* const BaseRoot = TEXT("/Game/Heavy/StreetMap/");
const TCHAR* const StaticRoot = TEXT("/Game/Heavy/StreetMap/Static/");
// Dossier lu par UI/CityMenu (GetAllFilesFromFolder) et noms du menu des villes (MAPDL1-L1-CART-02).
const TCHAR* const MenuFolder = TEXT("Heavy/StreetMap");
const TCHAR* const ListName = TEXT("CityScrollBox");
const TCHAR* const MenuClassName = TEXT("CityMenu_C");
const TCHAR* const RowClassName = TEXT("City_C");
// Villes pleines du jeu de base, si l'index est absent ou illisible.
const TCHAR* const DefaultBase[] = { TEXT("CentralLondon"), TEXT("00BathCC") };
// Textes du jeu (anglais, seuls textes en dur de ce lot : fiche MAPDL1-E1 § 2.3 et § 2.4).
const TCHAR* const TextUnavailable = TEXT("Not available");
const TCHAR* const TextRestart = TEXT("Restart required");
const TCHAR* const BannerDownloadable = TEXT("Map not installed - download it from the city menu");
const TCHAR* const BannerUnavailable = TEXT("Map not available");
constexpr double BannerSeconds = 10.0;
constexpr int32 BannerZOrder = 1100;
constexpr float UnavailableOpacity = 0.6f;
// Recherche de la liste (TObjectIterator) : pas a chaque image. Liste connue : on ne cherche qu'un remplacant.
constexpr double ListSearchIdle = 0.1;
constexpr double ListSearchActive = 0.5;
constexpr FLinearColor DownloadColor(0.2f, 0.6f, 1.0f, 1.0f);
constexpr FLinearColor UnavailableColor(0.5f, 0.5f, 0.5f, 1.0f);
constexpr FLinearColor RestartColor(0.6f, 0.6f, 1.0f, 1.0f);
// Instance qui a pose le filtre de ModdingExtra (comparaison d'adresse seulement, jamais dereferencee).
const void* GFilterOwner = nullptr;
const TCHAR* StateName(ELPCityMapState State)
{
switch (State)
{
case ELPCityMapState::Installed: return TEXT("Installed");
case ELPCityMapState::Downloadable: return TEXT("Downloadable");
case ELPCityMapState::RestartRequired: return TEXT("RestartRequired");
default: return TEXT("Unavailable");
}
}
/** Groupe de tri du menu : installees (0), telechargeables dont "Restart required" (1), indisponibles (2). */
int32 SortGroup(ELPCityMapState State)
{
switch (State)
{
case ELPCityMapState::Installed: return 0;
case ELPCityMapState::Downloadable:
case ELPCityMapState::RestartRequired: return 1;
default: return 2;
}
}
bool IsAllDigits(const FString& Text)
{
if (Text.IsEmpty())
{
return false;
}
for (const TCHAR C : Text)
{
if (!FChar::IsDigit(C))
{
return false;
}
}
return true;
}
bool IsHex64(const FString& Text)
{
if (Text.Len() != 64)
{
return false;
}
for (const TCHAR C : Text)
{
if (!FChar::IsHexDigit(C))
{
return false;
}
}
return true;
}
bool IsHttps(const FString& Url)
{
return Url.StartsWith(TEXT("https://"), ESearchCase::IgnoreCase) && Url.Len() > 8;
}
/** <0, 0, >0. Composantes separees par des points ; numerique quand les deux sont des nombres, sinon textuelle (casse
* ignoree) ; une composante absente vaut "0". */
int32 CompareVersionText(const FString& Left, const FString& Right)
{
TArray<FString> PartsLeft, PartsRight;
Left.TrimStartAndEnd().ParseIntoArray(PartsLeft, TEXT("."), false);
Right.TrimStartAndEnd().ParseIntoArray(PartsRight, TEXT("."), false);
const int32 Count = FMath::Max(PartsLeft.Num(), PartsRight.Num());
for (int32 Index = 0; Index < Count; ++Index)
{
const FString A = PartsLeft.IsValidIndex(Index) ? PartsLeft[Index] : FString(TEXT("0"));
const FString B = PartsRight.IsValidIndex(Index) ? PartsRight[Index] : FString(TEXT("0"));
int32 Order = 0;
if (IsAllDigits(A) && IsAllDigits(B) && A.Len() <= 18 && B.Len() <= 18)
{
const int64 NumberA = FCString::Atoi64(*A);
const int64 NumberB = FCString::Atoi64(*B);
Order = NumberA < NumberB ? -1 : (NumberA > NumberB ? 1 : 0);
}
else
{
const int32 Lexical = A.Compare(B, ESearchCase::IgnoreCase);
Order = Lexical < 0 ? -1 : (Lexical > 0 ? 1 : 0);
}
if (Order != 0)
{
return Order;
}
}
return 0;
}
/** Dossier normalise : "\" devient "/", sans "/" au debut ni a la fin. */
FString NormalizeFolder(FString Folder)
{
Folder.ReplaceInline(TEXT("\\"), TEXT("/"));
while (Folder.StartsWith(TEXT("/")))
{
Folder.RightChopInline(1);
}
while (Folder.EndsWith(TEXT("/")))
{
Folder.LeftChopInline(1);
}
return Folder;
}
/** Nom de ville de la variable City (structure /Game/Structs/City) d'une ligne City_C : champ StreetMapName*, partie
* apres le dernier "." du chemin, ou apres le dernier "/" s'il n'y a pas de ".". Faux si la valeur est vide ou illisible. */
bool ReadRowCity(const UUserWidget* Row, FString& OutCity)
{
const FStructProperty* CityProperty = CastField<FStructProperty>(Row->GetClass()->FindPropertyByName(TEXT("City")));
if (!CityProperty || !CityProperty->Struct)
{
return false;
}
const void* StructAddress = CityProperty->ContainerPtrToValuePtr<void>(Row);
for (TFieldIterator<FProperty> It(CityProperty->Struct); It; ++It)
{
const FProperty* Field = *It;
// Les champs d'une structure Blueprint portent un suffixe (StreetMapName_12_...).
if (!Field->GetName().StartsWith(TEXT("StreetMapName")))
{
continue;
}
FString Raw;
if (const FNameProperty* NameField = CastField<FNameProperty>(Field))
{
Raw = NameField->GetPropertyValue_InContainer(StructAddress).ToString();
}
else if (const FStrProperty* StringField = CastField<FStrProperty>(Field))
{
Raw = StringField->GetPropertyValue_InContainer(StructAddress);
}
else
{
return false;
}
int32 Cut = INDEX_NONE;
if (Raw.FindLastChar(TEXT('.'), Cut) || Raw.FindLastChar(TEXT('/'), Cut))
{
OutCity = Raw.Mid(Cut + 1);
}
else
{
OutCity = Raw;
}
return !OutCity.IsEmpty();
}
return false;
}
/** Widget d'une ligne par sa variable (FObjectProperty du nom), sinon par son nom dans l'arbre. */
template <typename WidgetType>
WidgetType* FindRowWidget(UUserWidget* Row, const TCHAR* Name)
{
if (const FObjectProperty* Property = CastField<FObjectProperty>(Row->GetClass()->FindPropertyByName(Name)))
{
if (WidgetType* Widget = Cast<WidgetType>(Property->GetObjectPropertyValue_InContainer(Row)))
{
return Widget;
}
}
return Cast<WidgetType>(Row->GetWidgetFromName(Name));
}
void ReadStringArray(const TSharedPtr<FJsonObject>& Object, const TCHAR* Field, TArray<FString>& Out)
{
const TArray<TSharedPtr<FJsonValue>>* Values = nullptr;
if (Object->TryGetArrayField(Field, Values) && Values)
{
for (const TSharedPtr<FJsonValue>& Value : *Values)
{
FString Text;
if (Value.IsValid() && Value->TryGetString(Text))
{
Out.Add(Text);
}
}
}
}
}
void ULPCityRowClick::OnClicked()
{
if (ULPCityMapsSubsystem* Subsystem = Owner.Get())
{
// E5 : la fenetre de telechargement (OpenCityPage ne sert plus que de recours).
Subsystem->OpenCityWindow(Ville);
}
}
void ULPCityMapsSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
LoadIndex();
// C2 : filtre de la liste de GetAllFilesFromFolder (ModdingExtra ne depend pas du jeu).
LPCityMapsImpl::GFilterOwner = this;
ModdingExtraSetFileListFilter([WeakThis = TWeakObjectPtr<ULPCityMapsSubsystem>(this)](const FString& RootFolder, const FString& Ext, TArray<FString>& Files)
{
if (ULPCityMapsSubsystem* Self = WeakThis.Get())
{
Self->FilterFileList(RootFolder, Ext, Files);
}
});
// C3 et fin du bandeau : chaque image, comme TickModManager.
TickHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateUObject(this, &ULPCityMapsSubsystem::Tick), 0.f);
// C4 : partie sauvegardee dont la carte n'est pas installee.
ResolvedHandle = ULPAssetRegistryHelpers::OnCityMapResolved().AddUObject(this, &ULPCityMapsSubsystem::HandleCityMapResolved);
}
void ULPCityMapsSubsystem::Deinitialize()
{
if (TickHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(TickHandle);
TickHandle.Reset();
}
if (ResolvedHandle.IsValid())
{
ULPAssetRegistryHelpers::OnCityMapResolved().Remove(ResolvedHandle);
ResolvedHandle.Reset();
}
// Filtre retire seulement s'il est le notre (une autre instance de jeu, en PIE, a pu le poser depuis).
if (LPCityMapsImpl::GFilterOwner == this)
{
ModdingExtraSetFileListFilter(FModdingExtraFileListFilter());
LPCityMapsImpl::GFilterOwner = nullptr;
}
// E5 : annule le telechargement en cours (le .part est garde) et ferme la fenetre.
if (Window.IsValid())
{
Window->Close();
Window.Reset();
}
CloseBanner();
ActiveList.Reset();
RowClicks.Reset();
BoundButtons.Reset();
Super::Deinitialize();
}
void ULPCityMapsSubsystem::LoadIndex()
{
BaseCities.Reset();
Licences.Reset();
Entries.Reset();
StateCache.Reset();
IndexPath.Reset();
const auto UseDefaultBase = [this]()
{
for (const TCHAR* City : LPCityMapsImpl::DefaultBase)
{
BaseCities.Add(City);
}
};
// (a) surcharge locale pour les essais, (b) copie livree : le premier fichier present est lu.
const FString Candidates[] = {
FPaths::ProjectSavedDir() / TEXT("LPCityMaps.json"),
FPaths::ProjectContentDir() / TEXT("Data/LPCityMaps.json")
};
for (const FString& Candidate : Candidates)
{
if (IFileManager::Get().FileExists(*Candidate))
{
IndexPath = Candidate;
break;
}
}
if (IndexPath.IsEmpty())
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CARTES index absent (%s, %s) : aucune ville telechargeable"), *Candidates[0], *Candidates[1]);
UseDefaultBase();
return;
}
FString Text;
TSharedPtr<FJsonObject> Root;
if (!FFileHelper::LoadFileToString(Text, *IndexPath) || !FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Text), Root) || !Root.IsValid())
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CARTES index illisible %s : aucune ville telechargeable"), *IndexPath);
UseDefaultBase();
return;
}
int32 Format = 0;
if (!Root->TryGetNumberField(TEXT("format"), Format) || Format != 2)
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CARTES index %s : format %d, format 2 attendu (lecture tentee quand meme)"), *IndexPath, Format);
}
LPCityMapsImpl::ReadStringArray(Root, TEXT("base"), BaseCities);
if (BaseCities.Num() == 0)
{
UseDefaultBase();
}
const TSharedPtr<FJsonObject>* LicencesObject = nullptr;
if (Root->TryGetObjectField(TEXT("licences"), LicencesObject) && LicencesObject && LicencesObject->IsValid())
{
// Cles de FJsonObject::Values : UE::FSharedString en 5.8 (ToView), comme la correction R2 de LPDataCache.cpp.
for (const auto& Pair : (*LicencesObject)->Values)
{
const TSharedPtr<FJsonObject>* Item = nullptr;
if (Pair.Value.IsValid() && Pair.Value->TryGetObject(Item) && Item && Item->IsValid())
{
FLPCityMapLicence Licence;
(*Item)->TryGetStringField(TEXT("titre"), Licence.Titre);
(*Item)->TryGetStringField(TEXT("fichier"), Licence.Fichier);
Licences.Add(FString(Pair.Key.ToView()), Licence);
}
}
}
const TArray<TSharedPtr<FJsonValue>>* Cities = nullptr;
if (Root->TryGetArrayField(TEXT("villes"), Cities) && Cities)
{
for (const TSharedPtr<FJsonValue>& Value : *Cities)
{
const TSharedPtr<FJsonObject>* ObjectPointer = nullptr;
if (!Value.IsValid() || !Value->TryGetObject(ObjectPointer) || !ObjectPointer || !ObjectPointer->IsValid())
{
continue;
}
const TSharedPtr<FJsonObject>& Object = *ObjectPointer;
FLPCityMapEntry Entry;
Object->TryGetStringField(TEXT("ville"), Entry.Ville);
Object->TryGetStringField(TEXT("pack"), Entry.Pack);
Object->TryGetStringField(TEXT("version"), Entry.Version);
Object->TryGetStringField(TEXT("jeu_min"), Entry.JeuMin);
Object->TryGetStringField(TEXT("jeu_max"), Entry.JeuMax);
Object->TryGetStringField(TEXT("zip"), Entry.Zip);
Object->TryGetNumberField(TEXT("octets"), Entry.Octets);
Object->TryGetStringField(TEXT("sha256"), Entry.Sha256);
Object->TryGetStringField(TEXT("page"), Entry.Page);
Object->TryGetStringField(TEXT("source"), Entry.Source);
LPCityMapsImpl::ReadStringArray(Object, TEXT("licences"), Entry.Licences);
if (Entry.Ville.IsEmpty())
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CARTES index %s : entree sans ville ignoree"), *IndexPath);
continue;
}
// HTTPS seulement (SPEC-05 v3 § 2, surete) : les autres liens sont ecartes, la ville n'est pas telechargeable sans lien valide.
TArray<FString> RawUrls;
LPCityMapsImpl::ReadStringArray(Object, TEXT("urls"), RawUrls);
for (const FString& Url : RawUrls)
{
if (LPCityMapsImpl::IsHttps(Url))
{
Entry.Urls.Add(Url);
}
else
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CARTES %s : lien non HTTPS ecarte"), *Entry.Ville);
}
}
if (!Entry.Page.IsEmpty() && !LPCityMapsImpl::IsHttps(Entry.Page))
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CARTES %s : page non HTTPS ecartee"), *Entry.Ville);
Entry.Page.Reset();
}
Entries.Add(MoveTemp(Entry));
}
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] CARTES index %s : base %d, villes %d"), *IndexPath, BaseCities.Num(), Entries.Num());
}
bool ULPCityMapsSubsystem::IsGameVersionInRange(const FLPCityMapEntry& Entry) const
{
const FString& Game = FLPModGuard::GetGameVersion();
// Borne absente : pas de limite de ce cote.
if (!Entry.JeuMin.IsEmpty() && LPCityMapsImpl::CompareVersionText(Game, Entry.JeuMin) < 0)
{
return false;
}
if (!Entry.JeuMax.IsEmpty() && LPCityMapsImpl::CompareVersionText(Game, Entry.JeuMax) > 0)
{
return false;
}
return true;
}
const FLPCityStateInfo& ULPCityMapsSubsystem::EvaluateCity(const FString& Ville)
{
const FString Key = Ville.ToLower();
if (const FLPCityStateInfo* Cached = StateCache.Find(Key))
{
return *Cached;
}
FLPCityStateInfo Info;
bool bInstalled = BaseCities.ContainsByPredicate([&Ville](const FString& Base) { return Base.Equals(Ville, ESearchCase::IgnoreCase); });
if (!bInstalled && !Ville.IsEmpty())
{
// Pack monte : la carte ET le maillage Static sont redirigees (le cache de ResolveCityPath n'est jamais invalide).
const FString Plain = FString::Printf(TEXT("%s%s.%s"), LPCityMapsImpl::BaseRoot, *Ville, *Ville);
const FString Static = FString::Printf(TEXT("%s%s.%s"), LPCityMapsImpl::StaticRoot, *Ville, *Ville);
bInstalled = ULPAssetRegistryHelpers::ResolveCityPath(Plain) != Plain && ULPAssetRegistryHelpers::ResolveCityPath(Static) != Static;
}
if (bInstalled)
{
Info.State = ELPCityMapState::Installed;
}
else
{
for (int32 Index = 0; Index < Entries.Num(); ++Index)
{
const FLPCityMapEntry& Entry = Entries[Index];
if (Entry.Ville.Equals(Ville, ESearchCase::IgnoreCase) && Entry.Urls.Num() > 0 && LPCityMapsImpl::IsHex64(Entry.Sha256)
&& IsGameVersionInRange(Entry))
{
// Pack pose dans cette session (E5) : actif au prochain lancement seulement.
Info.State = RestartCities.Contains(Key) ? ELPCityMapState::RestartRequired : ELPCityMapState::Downloadable;
Info.EntryIndex = Index;
break;
}
}
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] ETAT %s : %s"), *Ville, LPCityMapsImpl::StateName(Info.State));
return StateCache.Add(Key, Info);
}
ELPCityMapState ULPCityMapsSubsystem::GetCityState(const FString& Ville)
{
return EvaluateCity(Ville).State;
}
const FLPCityMapEntry* ULPCityMapsSubsystem::FindDownloadEntry(const FString& Ville)
{
const FLPCityStateInfo& Info = EvaluateCity(Ville);
return (Info.State == ELPCityMapState::Downloadable && Entries.IsValidIndex(Info.EntryIndex)) ? &Entries[Info.EntryIndex] : nullptr;
}
int64 ULPCityMapsSubsystem::GetDownloadBytes(const FString& Ville)
{
const FLPCityMapEntry* Entry = FindDownloadEntry(Ville);
return Entry ? Entry->Octets : 0;
}
TArray<FString> ULPCityMapsSubsystem::GetDownloadUrls(const FString& Ville)
{
const FLPCityMapEntry* Entry = FindDownloadEntry(Ville);
return Entry ? Entry->Urls : TArray<FString>();
}
void ULPCityMapsSubsystem::FilterFileList(const FString& RootFolder, const FString& Ext, TArray<FString>& Files)
{
if (!LPCityMapsImpl::NormalizeFolder(RootFolder).Equals(LPCityMapsImpl::MenuFolder, ESearchCase::IgnoreCase))
{
return;
}
FString Extension = Ext;
Extension.RemoveFromStart(TEXT("."));
if (!Extension.Equals(TEXT("uasset"), ESearchCase::IgnoreCase))
{
return;
}
// Les entrees des villes installees restent : UI/City compte deux .uasset (carte et Static) pour afficher "Installed".
const int32 Before = Files.Num();
Files.RemoveAll([this](const FString& File)
{
return GetCityState(FPaths::GetBaseFilename(File)) != ELPCityMapState::Installed;
});
UE_LOG(LogLPCity, Log, TEXT("[LPCity] LISTE Heavy/StreetMap : %d entrees, %d retirees"), Before, Before - Files.Num());
}
bool ULPCityMapsSubsystem::IsListOnScreen(const UScrollBox* List) const
{
static const FName ListFName(LPCityMapsImpl::ListName);
static const FName MenuClassFName(LPCityMapsImpl::MenuClassName);
if (!IsValid(List) || List->HasAnyFlags(RF_ClassDefaultObject | RF_ArchetypeObject | RF_BeginDestroyed | RF_FinishDestroyed) || List->GetFName() != ListFName)
{
return false;
}
// Objet exterieur : le CityMenu_C de cette instance de jeu (une ancienne instance attend le ramasse-miettes).
const UUserWidget* Menu = List->GetTypedOuter<UUserWidget>();
if (!Menu || Menu->HasAnyFlags(RF_ClassDefaultObject) || Menu->GetClass()->GetFName() != MenuClassFName || Menu->GetGameInstance() != GetGameInstance())
{
return false;
}
return List->IsVisible() && List->GetCachedWidget().IsValid();
}
UScrollBox* ULPCityMapsSubsystem::FindCityList() const
{
UScrollBox* Best = nullptr;
int32 BestScore = 0;
for (TObjectIterator<UScrollBox> It; It; ++It)
{
UScrollBox* Candidate = *It;
if (!IsListOnScreen(Candidate))
{
continue;
}
// A egalite : l'instance au viewport d'abord, puis celle deja suivie.
const UUserWidget* Menu = Candidate->GetTypedOuter<UUserWidget>();
const int32 Score = 1 + ((Menu && Menu->IsInViewport()) ? 2 : 0) + (Candidate == ActiveList.Get() ? 1 : 0);
if (Score > BestScore)
{
Best = Candidate;
BestScore = Score;
}
}
return Best;
}
bool ULPCityMapsSubsystem::Tick(float DeltaTime)
{
(void)DeltaTime;
// E5 : la fenetre fermee est liberee ici, jamais depuis son propre Close.
if (Window.IsValid() && !Window->IsOpen())
{
Window.Reset();
}
const double Now = FPlatformTime::Seconds();
if (Banner.IsValid() && Now >= BannerExpiry)
{
CloseBanner();
}
UScrollBox* List = ActiveList.Get();
if (List && !IsListOnScreen(List))
{
List = nullptr;
ActiveList.Reset();
NextListSearch = 0.0;
}
if (Now >= NextListSearch)
{
UScrollBox* Found = FindCityList();
NextListSearch = Now + (Found ? LPCityMapsImpl::ListSearchActive : LPCityMapsImpl::ListSearchIdle);
if (Found)
{
List = Found;
}
}
if (!List)
{
return true;
}
if (List != ActiveList.Get())
{
// Nouvelle instance du menu : tri a refaire, relais de clic de l'ancienne liste vides.
ActiveList = List;
SortedChildCount = -1;
bSortLogged = false;
RowClicks.Reset();
BoundButtons.Reset();
}
const int32 Count = List->GetChildrenCount();
if (Count > 0 && Count != SortedChildCount)
{
SortRows(List);
}
// Chaque image : le Blueprint ecrit InstalledText apres un Delay 0.2 (MAPDL1-L4-CART-01).
DecorateRows(List);
return true;
}
void ULPCityMapsSubsystem::SortRows(UScrollBox* List)
{
static const FName RowClassFName(LPCityMapsImpl::RowClassName);
struct FRowItem
{
UWidget* Widget = nullptr;
FMargin Padding;
EHorizontalAlignment Horizontal = HAlign_Fill;
EVerticalAlignment Vertical = VAlign_Fill;
};
// Trois groupes, ordre d'origine garde dans chacun. Une ligne dont la ville est illisible reste avec les installees.
TArray<FRowItem> Groups[3];
TArray<UWidget*> Before;
const int32 Count = List->GetChildrenCount();
for (int32 Index = 0; Index < Count; ++Index)
{
UWidget* Child = List->GetChildAt(Index);
if (!Child)
{
continue;
}
Before.Add(Child);
FRowItem Item;
Item.Widget = Child;
if (const UScrollBoxSlot* Slot = Cast<UScrollBoxSlot>(Child->Slot))
{
Item.Padding = Slot->GetPadding();
Item.Horizontal = Slot->GetHorizontalAlignment();
Item.Vertical = Slot->GetVerticalAlignment();
}
int32 Group = 0;
FString Ville;
const UUserWidget* Row = Cast<UUserWidget>(Child);
if (Row && Row->GetClass()->GetFName() == RowClassFName && LPCityMapsImpl::ReadRowCity(Row, Ville))
{
Group = LPCityMapsImpl::SortGroup(GetCityState(Ville));
}
Groups[Group].Add(Item);
}
TArray<FRowItem> Ordered;
for (const TArray<FRowItem>& Group : Groups)
{
Ordered.Append(Group);
}
bool bSameOrder = Ordered.Num() == Before.Num();
for (int32 Index = 0; bSameOrder && Index < Ordered.Num(); ++Index)
{
bSameOrder = Ordered[Index].Widget == Before[Index];
}
if (!bSameOrder)
{
// Retrait de tous les enfants puis reinsertion dans le nouvel ordre ; marges et alignements de chaque ligne repris.
List->ClearChildren();
for (const FRowItem& Item : Ordered)
{
if (UScrollBoxSlot* NewSlot = Cast<UScrollBoxSlot>(List->AddChild(Item.Widget)))
{
NewSlot->SetPadding(Item.Padding);
NewSlot->SetHorizontalAlignment(Item.Horizontal);
NewSlot->SetVerticalAlignment(Item.Vertical);
}
}
}
SortedChildCount = List->GetChildrenCount();
if (!bSortLogged)
{
bSortLogged = true;
UE_LOG(LogLPCity, Log, TEXT("[LPCity] MENU tri : %d installees, %d telechargeables, %d indisponibles"), Groups[0].Num(), Groups[1].Num(), Groups[2].Num());
}
}
void ULPCityMapsSubsystem::DecorateRows(UScrollBox* List)
{
static const FName RowClassFName(LPCityMapsImpl::RowClassName);
const int32 Count = List->GetChildrenCount();
for (int32 Index = 0; Index < Count; ++Index)
{
UUserWidget* Row = Cast<UUserWidget>(List->GetChildAt(Index));
FString Ville;
if (!Row || Row->GetClass()->GetFName() != RowClassFName || !LPCityMapsImpl::ReadRowCity(Row, Ville))
{
continue;
}
const ELPCityMapState State = GetCityState(Ville);
if (State == ELPCityMapState::Installed)
{
// Texte et clic d'origine : rien a toucher.
continue;
}
UTextBlock* Text = LPCityMapsImpl::FindRowWidget<UTextBlock>(Row, TEXT("InstalledText"));
UButton* Button = LPCityMapsImpl::FindRowWidget<UButton>(Row, TEXT("CityButton"));
if (State == ELPCityMapState::Downloadable)
{
if (Text)
{
// Mo superieur (1 Mo = 1 048 576 octets, comme l'affichage de Windows).
const int64 MegaBytes = FMath::Min<int64>((GetDownloadBytes(Ville) + 1048575) / 1048576, MAX_int32);
const FString Label = FString::Printf(TEXT("Download (%d MB)"), static_cast<int32>(MegaBytes));
if (!Text->GetText().ToString().Equals(Label, ESearchCase::CaseSensitive))
{
Text->SetText(FText::FromString(Label));
}
if (!(Text->GetColorAndOpacity().GetSpecifiedColor() == LPCityMapsImpl::DownloadColor))
{
Text->SetColorAndOpacity(FSlateColor(LPCityMapsImpl::DownloadColor));
}
}
if (Button && !BoundButtons.Contains(TWeakObjectPtr<UObject>(Button)))
{
ULPCityRowClick* Relay = NewObject<ULPCityRowClick>(this);
Relay->Ville = Ville;
Relay->Owner = this;
Button->OnClicked.AddUniqueDynamic(Relay, &ULPCityRowClick::OnClicked);
RowClicks.Add(Relay);
BoundButtons.Add(TWeakObjectPtr<UObject>(Button));
}
}
else if (State == ELPCityMapState::RestartRequired)
{
if (Text)
{
if (!Text->GetText().ToString().Equals(LPCityMapsImpl::TextRestart, ESearchCase::CaseSensitive))
{
Text->SetText(FText::FromString(LPCityMapsImpl::TextRestart));
}
if (!(Text->GetColorAndOpacity().GetSpecifiedColor() == LPCityMapsImpl::RestartColor))
{
Text->SetColorAndOpacity(FSlateColor(LPCityMapsImpl::RestartColor));
}
}
// Clic sans effet : le Blueprint bloque deja une ville absente de la liste, et le bouton est desactive en plus.
if (Button && Button->GetIsEnabled())
{
Button->SetIsEnabled(false);
}
}
else
{
if (Text)
{
if (!Text->GetText().ToString().Equals(LPCityMapsImpl::TextUnavailable, ESearchCase::CaseSensitive))
{
Text->SetText(FText::FromString(LPCityMapsImpl::TextUnavailable));
}
if (!(Text->GetColorAndOpacity().GetSpecifiedColor() == LPCityMapsImpl::UnavailableColor))
{
Text->SetColorAndOpacity(FSlateColor(LPCityMapsImpl::UnavailableColor));
}
}
if (Button && Button->GetIsEnabled())
{
Button->SetIsEnabled(false);
}
if (!FMath::IsNearlyEqual(Row->GetRenderOpacity(), LPCityMapsImpl::UnavailableOpacity))
{
Row->SetRenderOpacity(LPCityMapsImpl::UnavailableOpacity);
}
}
}
}
void ULPCityMapsSubsystem::OpenCityWindow(const FString& Ville)
{
const FLPCityMapEntry* Entry = FindDownloadEntry(Ville);
if (!Entry)
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CLIC %s : aucune entree telechargeable"), *Ville);
return;
}
// Une seule fenetre a la fois (et donc un seul telechargement).
if (Window.IsValid() && Window->IsOpen())
{
UE_LOG(LogLPCity, Log, TEXT("[LPCity] CLIC %s : une fenetre est deja ouverte"), *Ville);
return;
}
// Titres et fichiers des licences de l'entree, dans l'ordre de l'index.
TArray<FLPCityMapLicence> Used;
for (const FString& Key : Entry->Licences)
{
if (const FLPCityMapLicence* Found = Licences.Find(Key))
{
Used.Add(*Found);
}
else
{
FLPCityMapLicence Missing;
Missing.Titre = Key;
Used.Add(Missing);
}
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] CLIC %s : fenetre de telechargement"), *Ville);
Window = MakeShared<FLPCityMapsWindow>();
const TWeakObjectPtr<ULPCityMapsSubsystem> WeakThis(this);
const bool bOpened = Window->Open(*Entry, Used, [WeakThis](const FString& InstalledCity)
{
if (ULPCityMapsSubsystem* Self = WeakThis.Get())
{
Self->MarkRestartRequired(InstalledCity);
}
});
if (!bOpened)
{
Window.Reset();
}
}
void ULPCityMapsSubsystem::MarkRestartRequired(const FString& Ville)
{
const FString Key = Ville.ToLower();
RestartCities.Add(Key);
// L'etat est recalcule au prochain appel : RestartRequired tant que la ville n'est pas Installed.
StateCache.Remove(Key);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] ETAT %s : RestartRequired (pack pose dans cette session)"), *Ville);
}
void ULPCityMapsSubsystem::OpenCityPage(const FString& Ville)
{
const FLPCityMapEntry* Entry = FindDownloadEntry(Ville);
if (!Entry)
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CLIC %s : aucune entree telechargeable"), *Ville);
return;
}
// Recours (c) de la SPEC v3 § 3 : la page si elle existe, sinon le premier lien (la fenetre de C6 fait le meme choix).
const FString Url = !Entry->Page.IsEmpty() ? Entry->Page : (Entry->Urls.Num() > 0 ? Entry->Urls[0] : FString());
if (Url.IsEmpty())
{
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] CLIC %s : aucun lien"), *Ville);
return;
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] CLIC %s : navigateur %s"), *Ville, *Url);
FPlatformProcess::LaunchURL(*Url, nullptr, nullptr);
}
void ULPCityMapsSubsystem::HandleCityMapResolved(const FString& Requested, const FString& Resolved)
{
if (!Requested.StartsWith(LPCityMapsImpl::BaseRoot, ESearchCase::IgnoreCase))
{
return;
}
// /Game/Heavy/StreetMap/<V>.<V> seulement (pas Static/, Water/, Landmarks/, Houses/).
const FString Relative = Requested.Mid(FCString::Strlen(LPCityMapsImpl::BaseRoot));
if (Relative.IsEmpty() || Relative.Contains(TEXT("/")))
{
return;
}
FString Ville = Relative;
int32 Dot = INDEX_NONE;
if (Relative.FindChar(TEXT('.'), Dot))
{
Ville = Relative.Left(Dot);
}
// Chemin resolu egal au demande : aucun pack ne remplace la carte.
if (Ville.IsEmpty() || !Resolved.Equals(Requested, ESearchCase::IgnoreCase))
{
return;
}
const ELPCityMapState State = GetCityState(Ville);
if (State == ELPCityMapState::Installed || BannerShown.Contains(Ville.ToLower()))
{
return;
}
const FString Text = (State == ELPCityMapState::Downloadable) ? FString(LPCityMapsImpl::BannerDownloadable) : FString(LPCityMapsImpl::BannerUnavailable);
if (ShowBanner(Text))
{
BannerShown.Add(Ville.ToLower());
UE_LOG(LogLPCity, Log, TEXT("[LPCity] BANDEAU %s : %s"), *Ville, *Text);
}
}
bool ULPCityMapsSubsystem::ShowBanner(const FString& Text)
{
if (!GEngine || !GEngine->GameViewport)
{
return false;
}
CloseBanner();
Banner = SNew(SOverlay).Visibility(EVisibility::HitTestInvisible)
+ SOverlay::Slot().HAlign(HAlign_Center).VAlign(VAlign_Top)
[
SNew(SBox).Padding(FMargin(0.f, 48.f, 0.f, 0.f))
[
SNew(SBorder)
.BorderImage(FCoreStyle::Get().GetBrush("WhiteBrush"))
.BorderBackgroundColor(FLinearColor(0.02f, 0.02f, 0.03f, 0.92f))
.Padding(FMargin(24.f, 10.f))
[
SNew(STextBlock)
.Text(FText::FromString(Text))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 18))
.ColorAndOpacity(FLinearColor(1.f, 0.75f, 0.2f))
]
]
];
GEngine->GameViewport->AddViewportWidgetContent(Banner.ToSharedRef(), LPCityMapsImpl::BannerZOrder);
BannerExpiry = FPlatformTime::Seconds() + LPCityMapsImpl::BannerSeconds;
return true;
}
void ULPCityMapsSubsystem::CloseBanner()
{
if (Banner.IsValid() && GEngine && GEngine->GameViewport)
{
GEngine->GameViewport->RemoveViewportWidgetContent(Banner.ToSharedRef());
}
Banner.Reset();
}