946 lines
30 KiB
C++
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();
|
|
}
|