feat: add ue_scene websocket integration

This commit is contained in:
opencode
2026-05-09 11:32:07 +08:00
parent 61baee00f6
commit 8f67d8d108
12 changed files with 2684 additions and 49 deletions

View File

@@ -0,0 +1,22 @@
using UnrealBuildTool;
public class Planet : ModuleRules
{
public Planet(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(
new[]
{
"Core",
"CoreUObject",
"Engine",
"HTTP",
"Json",
"JsonUtilities",
"WebSockets",
}
);
}
}

View File

@@ -0,0 +1,677 @@
#include "UeSceneWebSocketClient.h"
#include "Dom/JsonObject.h"
#include "Dom/JsonValue.h"
#include "HttpModule.h"
#include "JsonObjectConverter.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "WebSocketsModule.h"
namespace
{
constexpr double GeoPointMergeTolerance = 1e-4;
bool TryParseGeoPoint(const TSharedPtr<FJsonObject>& PointObject, FVector& OutGeoPoint)
{
if (!PointObject.IsValid())
{
return false;
}
double Latitude = 0.0;
double Longitude = 0.0;
double Altitude = 0.0;
if (!PointObject->TryGetNumberField(TEXT("lat"), Latitude)
|| !PointObject->TryGetNumberField(TEXT("lng"), Longitude))
{
return false;
}
PointObject->TryGetNumberField(TEXT("alt"), Altitude);
OutGeoPoint = FVector(Longitude, Latitude, Altitude);
return true;
}
FString ResolveCableName(const TSharedPtr<FJsonObject>& ItemObject)
{
FString CableName;
const TSharedPtr<FJsonObject>* LabelsObject = nullptr;
if (ItemObject->TryGetObjectField(TEXT("labels"), LabelsObject)
&& LabelsObject != nullptr
&& LabelsObject->IsValid())
{
(*LabelsObject)->TryGetStringField(TEXT("title"), CableName);
}
if (CableName.IsEmpty())
{
ItemObject->TryGetStringField(TEXT("id"), CableName);
}
return CableName;
}
bool AreGeoPointsConnected(const FVector& A, const FVector& B)
{
return FMath::Abs(A.X - B.X) <= GeoPointMergeTolerance
&& FMath::Abs(A.Y - B.Y) <= GeoPointMergeTolerance
&& FMath::Abs(A.Z - B.Z) <= GeoPointMergeTolerance;
}
void AppendMergedSegment(
TArray<TArray<FVector>>& MergedSegments,
const TArray<FVector>& IncomingSegment
)
{
if (IncomingSegment.Num() < 2)
{
return;
}
if (MergedSegments.Num() == 0)
{
MergedSegments.Add(IncomingSegment);
return;
}
TArray<FVector>& LastMergedSegment = MergedSegments.Last();
if (LastMergedSegment.Num() == 0)
{
LastMergedSegment = IncomingSegment;
return;
}
if (AreGeoPointsConnected(LastMergedSegment.Last(), IncomingSegment[0]))
{
for (int32 Index = 1; Index < IncomingSegment.Num(); ++Index)
{
LastMergedSegment.Add(IncomingSegment[Index]);
}
return;
}
MergedSegments.Add(IncomingSegment);
}
}
UUeSceneWebSocketClient::UUeSceneWebSocketClient()
{
PrimaryComponentTick.bCanEverTick = false;
}
void UUeSceneWebSocketClient::BeginPlay()
{
Super::BeginPlay();
}
void UUeSceneWebSocketClient::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
DisconnectFromBackend();
Super::EndPlay(EndPlayReason);
}
void UUeSceneWebSocketClient::ConnectToBackend()
{
if (Username.IsEmpty() || Password.IsEmpty())
{
EmitError(TEXT("Username / Password 不能为空。"));
return;
}
RequestAccessToken();
}
void UUeSceneWebSocketClient::DisconnectFromBackend()
{
if (Socket.IsValid())
{
Socket->Close();
Socket.Reset();
}
PendingFullScene.Reset();
OnConnectedChanged.Broadcast(false);
}
void UUeSceneWebSocketClient::SendSubscribe()
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetStringField(TEXT("type"), TEXT("subscribe"));
TSharedRef<FJsonObject> Data = MakeShared<FJsonObject>();
TArray<TSharedPtr<FJsonValue>> Channels;
for (const FString& Channel : DefaultChannels)
{
Channels.Add(MakeShared<FJsonValueString>(Channel));
}
Data->SetArrayField(TEXT("channels"), Channels);
Root->SetObjectField(TEXT("data"), Data);
FString Payload;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Payload);
FJsonSerializer::Serialize(Root, Writer);
SendJsonString(Payload);
}
void UUeSceneWebSocketClient::SendInitialSyncRequest()
{
SendSyncRequest(TEXT("initial_connect"), INDEX_NONE);
}
void UUeSceneWebSocketClient::SendSyncRequest(const FString& Reason, int32 InLastSequence)
{
TSharedRef<FJsonObject> Root = MakeShared<FJsonObject>();
Root->SetStringField(TEXT("type"), TEXT("sync_request"));
TSharedRef<FJsonObject> Data = MakeShared<FJsonObject>();
Data->SetStringField(TEXT("channel"), TEXT("ue_scene"));
Data->SetStringField(TEXT("reason"), Reason);
if (InLastSequence >= 0)
{
Data->SetNumberField(TEXT("last_sequence"), InLastSequence);
}
Root->SetObjectField(TEXT("data"), Data);
FString Payload;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&Payload);
FJsonSerializer::Serialize(Root, Writer);
SendJsonString(Payload);
}
void UUeSceneWebSocketClient::RequestAccessToken()
{
TSharedRef<IHttpRequest, ESPMode::ThreadSafe> Request = FHttpModule::Get().CreateRequest();
Request->SetURL(BuildHttpUrl(TEXT("/api/v1/auth/login")));
Request->SetVerb(TEXT("POST"));
Request->SetHeader(TEXT("Content-Type"), TEXT("application/x-www-form-urlencoded"));
Request->SetContentAsString(
FString::Printf(TEXT("username=%s&password=%s"), *Username, *Password)
);
Request->OnProcessRequestComplete().BindUObject(
this, &UUeSceneWebSocketClient::HandleLoginResponse
);
Request->ProcessRequest();
}
void UUeSceneWebSocketClient::HandleLoginResponse(
FHttpRequestPtr Request,
FHttpResponsePtr Response,
bool bSucceeded
)
{
if (!bSucceeded || !Response.IsValid())
{
EmitError(TEXT("登录请求失败。"));
return;
}
if (Response->GetResponseCode() < 200 || Response->GetResponseCode() >= 300)
{
EmitError(FString::Printf(TEXT("登录失败HTTP %d"), Response->GetResponseCode()));
return;
}
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Response->GetContentAsString());
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
EmitError(TEXT("登录响应 JSON 解析失败。"));
return;
}
AccessToken = Root->GetStringField(TEXT("access_token"));
if (AccessToken.IsEmpty())
{
EmitError(TEXT("登录响应里没有 access_token。"));
return;
}
OpenWebSocket();
}
void UUeSceneWebSocketClient::OpenWebSocket()
{
if (!FModuleManager::Get().IsModuleLoaded(TEXT("WebSockets")))
{
FModuleManager::Get().LoadModule(TEXT("WebSockets"));
}
Socket = FWebSocketsModule::Get().CreateWebSocket(BuildWsUrl());
BindSocketEvents();
Socket->Connect();
}
void UUeSceneWebSocketClient::BindSocketEvents()
{
if (!Socket.IsValid())
{
return;
}
Socket->OnConnected().AddLambda([this]()
{
OnConnectedChanged.Broadcast(true);
});
Socket->OnConnectionError().AddLambda([this](const FString& Error)
{
EmitError(FString::Printf(TEXT("WebSocket 连接失败: %s"), *Error));
});
Socket->OnClosed().AddLambda([this](int32 StatusCode, const FString& Reason, bool bWasClean)
{
OnConnectedChanged.Broadcast(false);
});
Socket->OnMessage().AddLambda([this](const FString& Message)
{
OnRawMessage.Broadcast(Message);
TryBroadcastSceneFrame(Message);
});
}
void UUeSceneWebSocketClient::EmitError(const FString& ErrorMessage)
{
OnError.Broadcast(ErrorMessage);
}
void UUeSceneWebSocketClient::TryBroadcastSceneFrame(const FString& Message)
{
TSharedPtr<FJsonObject> Root;
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(Message);
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
{
return;
}
FString Type;
if (!Root->TryGetStringField(TEXT("type"), Type) || Type != TEXT("data_frame"))
{
return;
}
FString Channel;
if (!Root->TryGetStringField(TEXT("channel"), Channel) || Channel != TEXT("ue_scene"))
{
return;
}
const TSharedPtr<FJsonObject>* DataObject = nullptr;
if (!Root->TryGetObjectField(TEXT("data"), DataObject) || DataObject == nullptr || !DataObject->IsValid())
{
return;
}
TSharedPtr<FJsonObject> EffectiveDataObject = *DataObject;
FString UpdateType = TEXT("unknown");
EffectiveDataObject->TryGetStringField(TEXT("update_type"), UpdateType);
int32 Sequence = 0;
EffectiveDataObject->TryGetNumberField(TEXT("sequence"), Sequence);
int32 TotalRecords = 0;
const TSharedPtr<FJsonObject>* PayloadObject = nullptr;
if (EffectiveDataObject->TryGetObjectField(TEXT("payload"), PayloadObject) && PayloadObject != nullptr && PayloadObject->IsValid())
{
const TSharedPtr<FJsonObject>* MetaObject = nullptr;
if ((*PayloadObject)->TryGetObjectField(TEXT("meta"), MetaObject) && MetaObject != nullptr && MetaObject->IsValid())
{
(*MetaObject)->TryGetNumberField(TEXT("total_records"), TotalRecords);
}
}
if (UpdateType == TEXT("full"))
{
TSharedPtr<FJsonObject> AssembledDataObject;
if (!TryAssembleFullScene(EffectiveDataObject, Sequence, TotalRecords, AssembledDataObject))
{
return;
}
if (AssembledDataObject.IsValid())
{
EffectiveDataObject = AssembledDataObject;
}
}
LastSequence = Sequence;
OnSceneFrame.Broadcast(UpdateType, Sequence, TotalRecords);
BroadcastScenePayload(UpdateType, Sequence, EffectiveDataObject);
}
bool UUeSceneWebSocketClient::TryAssembleFullScene(
const TSharedPtr<FJsonObject>& DataObject,
int32 Sequence,
int32 TotalRecords,
TSharedPtr<FJsonObject>& OutAssembledDataObject
)
{
OutAssembledDataObject = nullptr;
const TSharedPtr<FJsonObject>* PayloadObject = nullptr;
if (!DataObject->TryGetObjectField(TEXT("payload"), PayloadObject) || PayloadObject == nullptr || !PayloadObject->IsValid())
{
EmitError(TEXT("Full scene payload is missing."));
return false;
}
const TSharedPtr<FJsonObject>* MetaObject = nullptr;
if (!(*PayloadObject)->TryGetObjectField(TEXT("meta"), MetaObject) || MetaObject == nullptr || !MetaObject->IsValid())
{
EmitError(TEXT("Full scene meta is missing."));
return false;
}
bool bChunked = false;
(*MetaObject)->TryGetBoolField(TEXT("chunked"), bChunked);
if (!bChunked)
{
OutAssembledDataObject = DataObject;
PendingFullScene.Reset();
return true;
}
int32 TransportChunkIndex = 0;
int32 TransportChunkCount = 0;
int32 LayerChunkIndex = 0;
int32 LayerChunkCount = 0;
FString LayerName;
(*MetaObject)->TryGetNumberField(TEXT("transport_chunk_index"), TransportChunkIndex);
(*MetaObject)->TryGetNumberField(TEXT("transport_chunk_count"), TransportChunkCount);
(*MetaObject)->TryGetNumberField(TEXT("layer_chunk_index"), LayerChunkIndex);
(*MetaObject)->TryGetNumberField(TEXT("layer_chunk_count"), LayerChunkCount);
(*MetaObject)->TryGetStringField(TEXT("layer_name"), LayerName);
if (TransportChunkIndex <= 0 || TransportChunkCount <= 0 || LayerChunkIndex <= 0 || LayerChunkCount <= 0 || LayerName.IsEmpty())
{
EmitError(TEXT("Chunked full scene metadata is incomplete."));
PendingFullScene.Reset();
return false;
}
if (PendingFullScene.Sequence != Sequence)
{
PendingFullScene.Reset();
PendingFullScene.Sequence = Sequence;
PendingFullScene.ExpectedTransportChunkCount = TransportChunkCount;
PendingFullScene.TotalRecords = TotalRecords;
PendingFullScene.DisplayProfile = DataObject->GetObjectField(TEXT("display_profile"));
PendingFullScene.CameraState = DataObject->GetObjectField(TEXT("camera_state"));
PendingFullScene.Meta = MakeShared<FJsonObject>(*MetaObject->Get());
DataObject->TryGetStringField(TEXT("cluster_time"), PendingFullScene.ClusterTime);
}
if (PendingFullScene.Sequence != Sequence || PendingFullScene.ExpectedTransportChunkCount != TransportChunkCount)
{
EmitError(TEXT("Chunked full scene sequence mismatch."));
PendingFullScene.Reset();
return false;
}
if (PendingFullScene.ReceivedTransportChunkIndices.Contains(TransportChunkIndex))
{
return false;
}
PendingFullScene.ReceivedTransportChunkIndices.Add(TransportChunkIndex);
const TSharedPtr<FJsonObject>* LayersObject = nullptr;
if (!(*PayloadObject)->TryGetObjectField(TEXT("layers"), LayersObject) || LayersObject == nullptr || !LayersObject->IsValid())
{
EmitError(TEXT("Chunked full scene layers are missing."));
PendingFullScene.Reset();
return false;
}
const TSharedPtr<FJsonObject>* LayerDataObject = nullptr;
if (!(*LayersObject)->TryGetObjectField(LayerName, LayerDataObject) || LayerDataObject == nullptr || !LayerDataObject->IsValid())
{
EmitError(TEXT("Chunked full scene layer payload is missing."));
PendingFullScene.Reset();
return false;
}
FLayerChunkAccumulator& LayerAccumulator = PendingFullScene.Layers.FindOrAdd(LayerName);
if (LayerAccumulator.ExpectedChunkCount == 0)
{
LayerAccumulator.ExpectedChunkCount = LayerChunkCount;
LayerDataObject->Get()->TryGetNumberField(TEXT("revision"), LayerAccumulator.Revision);
}
else if (LayerAccumulator.ExpectedChunkCount != LayerChunkCount)
{
EmitError(TEXT("Layer chunk count changed while assembling full scene."));
PendingFullScene.Reset();
return false;
}
if (LayerAccumulator.ReceivedChunkIndices.Contains(LayerChunkIndex))
{
return false;
}
const TArray<TSharedPtr<FJsonValue>>* LayerItems = nullptr;
if (!LayerDataObject->Get()->TryGetArrayField(TEXT("items"), LayerItems) || LayerItems == nullptr)
{
EmitError(TEXT("Chunked full scene items are missing."));
PendingFullScene.Reset();
return false;
}
LayerAccumulator.ReceivedChunkIndices.Add(LayerChunkIndex);
LayerAccumulator.Items.Append(*LayerItems);
if (PendingFullScene.ReceivedTransportChunkIndices.Num() != PendingFullScene.ExpectedTransportChunkCount)
{
return false;
}
for (const TPair<FString, FLayerChunkAccumulator>& LayerPair : PendingFullScene.Layers)
{
if (LayerPair.Value.ReceivedChunkIndices.Num() != LayerPair.Value.ExpectedChunkCount)
{
return false;
}
}
TSharedRef<FJsonObject> AssembledDataObject = MakeShared<FJsonObject>();
AssembledDataObject->SetStringField(TEXT("update_type"), TEXT("full"));
AssembledDataObject->SetNumberField(TEXT("sequence"), Sequence);
AssembledDataObject->SetStringField(TEXT("cluster_time"), PendingFullScene.ClusterTime);
if (PendingFullScene.DisplayProfile.IsValid())
{
AssembledDataObject->SetObjectField(TEXT("display_profile"), PendingFullScene.DisplayProfile);
}
if (PendingFullScene.CameraState.IsValid())
{
AssembledDataObject->SetObjectField(TEXT("camera_state"), PendingFullScene.CameraState);
}
TSharedRef<FJsonObject> AssembledPayloadObject = MakeShared<FJsonObject>();
TSharedRef<FJsonObject> AssembledMetaObject = PendingFullScene.Meta.IsValid()
? MakeShared<FJsonObject>(*PendingFullScene.Meta.Get())
: MakeShared<FJsonObject>();
AssembledMetaObject->SetBoolField(TEXT("chunked"), false);
AssembledMetaObject->RemoveField(TEXT("layer_name"));
AssembledMetaObject->RemoveField(TEXT("layer_chunk_index"));
AssembledMetaObject->RemoveField(TEXT("layer_chunk_count"));
AssembledMetaObject->RemoveField(TEXT("transport_chunk_index"));
AssembledMetaObject->RemoveField(TEXT("transport_chunk_count"));
AssembledPayloadObject->SetObjectField(TEXT("meta"), AssembledMetaObject);
TSharedRef<FJsonObject> AssembledLayersObject = MakeShared<FJsonObject>();
for (TPair<FString, FLayerChunkAccumulator>& LayerPair : PendingFullScene.Layers)
{
TSharedRef<FJsonObject> LayerObject = MakeShared<FJsonObject>();
LayerObject->SetNumberField(TEXT("revision"), LayerPair.Value.Revision);
LayerObject->SetArrayField(TEXT("items"), LayerPair.Value.Items);
AssembledLayersObject->SetObjectField(LayerPair.Key, LayerObject);
}
AssembledPayloadObject->SetObjectField(TEXT("layers"), AssembledLayersObject);
AssembledDataObject->SetObjectField(TEXT("payload"), AssembledPayloadObject);
OutAssembledDataObject = AssembledDataObject;
PendingFullScene.Reset();
return true;
}
void UUeSceneWebSocketClient::BroadcastScenePayload(
const FString& UpdateType,
int32 Sequence,
const TSharedPtr<FJsonObject>& DataObject
)
{
FString PayloadJson;
TSharedRef<TJsonWriter<>> Writer = TJsonWriterFactory<>::Create(&PayloadJson);
if (FJsonSerializer::Serialize(DataObject.ToSharedRef(), Writer))
{
OnScenePayload.Broadcast(UpdateType, Sequence, PayloadJson);
}
BroadcastLegacySubmarineCableEvents(DataObject);
}
void UUeSceneWebSocketClient::BroadcastLegacySubmarineCableEvents(
const TSharedPtr<FJsonObject>& DataObject
)
{
if (!DataObject.IsValid())
{
return;
}
const TSharedPtr<FJsonObject>* PayloadObject = nullptr;
if (!DataObject->TryGetObjectField(TEXT("payload"), PayloadObject)
|| PayloadObject == nullptr
|| !PayloadObject->IsValid())
{
return;
}
const TSharedPtr<FJsonObject>* LayersObject = nullptr;
if (!(*PayloadObject)->TryGetObjectField(TEXT("layers"), LayersObject)
|| LayersObject == nullptr
|| !LayersObject->IsValid())
{
return;
}
const TSharedPtr<FJsonObject>* CableLayerObject = nullptr;
if (!(*LayersObject)->TryGetObjectField(TEXT("submarine_cables"), CableLayerObject)
|| CableLayerObject == nullptr
|| !CableLayerObject->IsValid())
{
return;
}
const TArray<TSharedPtr<FJsonValue>>* CableItems = nullptr;
if (!(*CableLayerObject)->TryGetArrayField(TEXT("items"), CableItems) || CableItems == nullptr)
{
return;
}
for (const TSharedPtr<FJsonValue>& CableItemValue : *CableItems)
{
const TSharedPtr<FJsonObject> CableItemObject = CableItemValue.IsValid()
? CableItemValue->AsObject()
: nullptr;
if (!CableItemObject.IsValid())
{
continue;
}
const FString CableName = ResolveCableName(CableItemObject);
const TSharedPtr<FJsonObject>* GeoObject = nullptr;
if (!CableItemObject->TryGetObjectField(TEXT("geo"), GeoObject)
|| GeoObject == nullptr
|| !GeoObject->IsValid())
{
continue;
}
const TArray<TSharedPtr<FJsonValue>>* SegmentArray = nullptr;
if ((*GeoObject)->TryGetArrayField(TEXT("segments"), SegmentArray) && SegmentArray != nullptr)
{
TArray<TArray<FVector>> MergedSegments;
for (const TSharedPtr<FJsonValue>& SegmentValue : *SegmentArray)
{
const TArray<TSharedPtr<FJsonValue>>* SegmentPoints = nullptr;
if (!SegmentValue.IsValid() || !SegmentValue->TryGetArray(SegmentPoints) || SegmentPoints == nullptr)
{
continue;
}
TArray<FVector> GeoPoints;
GeoPoints.Reserve(SegmentPoints->Num());
for (const TSharedPtr<FJsonValue>& PointValue : *SegmentPoints)
{
FVector GeoPoint = FVector::ZeroVector;
if (PointValue.IsValid() && TryParseGeoPoint(PointValue->AsObject(), GeoPoint))
{
GeoPoints.Add(GeoPoint);
}
}
AppendMergedSegment(MergedSegments, GeoPoints);
}
for (const TArray<FVector>& MergedSegment : MergedSegments)
{
if (MergedSegment.Num() >= 2)
{
OnSubmarineCableReceived.Broadcast(CableName, MergedSegment);
}
}
continue;
}
const TArray<TSharedPtr<FJsonValue>>* PathArray = nullptr;
if (!(*GeoObject)->TryGetArrayField(TEXT("path"), PathArray) || PathArray == nullptr)
{
continue;
}
TArray<FVector> GeoPoints;
GeoPoints.Reserve(PathArray->Num());
for (const TSharedPtr<FJsonValue>& PointValue : *PathArray)
{
FVector GeoPoint = FVector::ZeroVector;
if (PointValue.IsValid() && TryParseGeoPoint(PointValue->AsObject(), GeoPoint))
{
GeoPoints.Add(GeoPoint);
}
}
if (GeoPoints.Num() >= 2)
{
OnSubmarineCableReceived.Broadcast(CableName, GeoPoints);
}
}
}
FString UUeSceneWebSocketClient::BuildWsUrl() const
{
FString WsBase = BackendBaseUrl;
WsBase = WsBase.Replace(TEXT("http://"), TEXT("ws://"));
WsBase = WsBase.Replace(TEXT("https://"), TEXT("wss://"));
return FString::Printf(TEXT("%s/ws?token=%s"), *WsBase, *AccessToken);
}
FString UUeSceneWebSocketClient::BuildHttpUrl(const FString& Path) const
{
return FString::Printf(TEXT("%s%s"), *BackendBaseUrl, *Path);
}
void UUeSceneWebSocketClient::SendJsonString(const FString& JsonPayload)
{
if (!Socket.IsValid() || !Socket->IsConnected())
{
EmitError(TEXT("WebSocket 尚未连接。"));
return;
}
Socket->Send(JsonPayload);
}

View File

@@ -0,0 +1,159 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Interfaces/IHttpRequest.h"
#include "Interfaces/IHttpResponse.h"
#include "IWebSocket.h"
#include "UeSceneWebSocketClient.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUeSceneRawMessageEvent, const FString&, Message);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUeSceneConnectedEvent, bool, bConnected);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUeSceneErrorEvent, const FString&, ErrorMessage);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
FUeSceneFrameEvent,
const FString&,
UpdateType,
int32,
Sequence,
int32,
TotalRecords
);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(
FUeScenePayloadEvent,
const FString&,
UpdateType,
int32,
Sequence,
const FString&,
PayloadJson
);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
FUeSceneSubmarineCableEvent,
const FString&,
CableName,
const TArray<FVector>&,
GeoPoints
);
UCLASS(ClassGroup=(Planet), meta=(BlueprintSpawnableComponent))
class PLANET_API UUeSceneWebSocketClient : public UActorComponent
{
GENERATED_BODY()
public:
UUeSceneWebSocketClient();
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
FString BackendBaseUrl = TEXT("http://127.0.0.1:8000");
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
FString Username;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
FString Password;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
TArray<FString> DefaultChannels = {TEXT("ue_scene")};
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Planet|Backend")
int32 LastSequence = INDEX_NONE;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneConnectedEvent OnConnectedChanged;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneRawMessageEvent OnRawMessage;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneErrorEvent OnError;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeSceneFrameEvent OnSceneFrame;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend")
FUeScenePayloadEvent OnScenePayload;
UPROPERTY(BlueprintAssignable, Category="Planet|Backend|Legacy")
FUeSceneSubmarineCableEvent OnSubmarineCableReceived;
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void ConnectToBackend();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void DisconnectFromBackend();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void SendSubscribe();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void SendInitialSyncRequest();
UFUNCTION(BlueprintCallable, Category="Planet|Backend")
void SendSyncRequest(const FString& Reason, int32 InLastSequence);
protected:
virtual void BeginPlay() override;
virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
private:
struct FLayerChunkAccumulator
{
int32 Revision = 0;
int32 ExpectedChunkCount = 0;
TSet<int32> ReceivedChunkIndices;
TArray<TSharedPtr<FJsonValue>> Items;
};
struct FFullSceneAssemblyState
{
int32 Sequence = INDEX_NONE;
int32 ExpectedTransportChunkCount = 0;
int32 TotalRecords = 0;
FString ClusterTime;
TSharedPtr<FJsonObject> DisplayProfile;
TSharedPtr<FJsonObject> CameraState;
TSharedPtr<FJsonObject> Meta;
TMap<FString, FLayerChunkAccumulator> Layers;
TSet<int32> ReceivedTransportChunkIndices;
void Reset()
{
Sequence = INDEX_NONE;
ExpectedTransportChunkCount = 0;
TotalRecords = 0;
ClusterTime.Reset();
DisplayProfile.Reset();
CameraState.Reset();
Meta.Reset();
Layers.Reset();
ReceivedTransportChunkIndices.Reset();
}
};
FString AccessToken;
TSharedPtr<IWebSocket> Socket;
FFullSceneAssemblyState PendingFullScene;
void RequestAccessToken();
void HandleLoginResponse(FHttpRequestPtr Request, FHttpResponsePtr Response, bool bSucceeded);
void OpenWebSocket();
void BindSocketEvents();
void EmitError(const FString& ErrorMessage);
void TryBroadcastSceneFrame(const FString& Message);
bool TryAssembleFullScene(
const TSharedPtr<FJsonObject>& DataObject,
int32 Sequence,
int32 TotalRecords,
TSharedPtr<FJsonObject>& OutAssembledDataObject
);
void BroadcastScenePayload(
const FString& UpdateType,
int32 Sequence,
const TSharedPtr<FJsonObject>& DataObject
);
void BroadcastLegacySubmarineCableEvents(const TSharedPtr<FJsonObject>& DataObject);
FString BuildWsUrl() const;
FString BuildHttpUrl(const FString& Path) const;
void SendJsonString(const FString& JsonPayload);
};