feat(ui-ingame): UI in-game system + PlayerState + Component Registry + HUD composite
Espelho simétrico do front-end pattern (UUIFrontEndFlowSubsystem +
DA_FrontEndScreenSet + UUIPrimaryGameLayout_Base) para a UI in-game.
Pavimenta caminho pra Inventory, Skills, StatusWindow, EscapeMenu, etc.
## UI in-game system (Source/ZMMO/Game/UI/InGame/)
- EZMMOInGameUIState enum (None, Playing, StatusWindow, Inventory,
SkillTree, EscapeMenu, Loading) em Data/UI/InGameTypes.h
- UUIInGameScreenSet DataAsset: TMap<EZMMOInGameUIState, TSoftClassPtr<...>>
- UUIInGameFlowSubsystem (GameInstanceSubsystem) com SetState/ToggleScreen
- DA_InGameScreenSet em Content/ZMMO/UI/InGame/ mapeia Playing->WBP_HUD
- Layer routing: Playing -> UI.Layer.Game | menus -> GameMenu | loading -> Modal
## AHUD pattern (idiomatic UE5)
- AZMMOHUD : AHUD em Source/ZMMO/Game/Modes/
- AZMMOGameMode::HUDClass = AZMMOHUD::StaticClass()
- AZMMOHUD::BeginPlay chama UIInGameFlowSubsystem::StartInGame
- Pawn não cria mais HUD (separação de responsabilidades)
## PlayerState + Component Registry (Open-Closed)
- AZMMOPlayerState : APlayerState em Source/ZMMO/Game/Modes/
- AZMMOGameMode::PlayerStateClass = AZMMOPlayerState::StaticClass()
- UPROPERTY Config TArray<TSubclassOf<UActorComponent>> ComponentClasses
- DefaultGame.ini: +ComponentClasses=/Script/ZMMOAttributes.ZMMOAttributeComponent
- Ctor lê via GConfig (mais robusto que UPROPERTY Config + parsing) e
instancia cada componente como CreateDefaultSubobject
Por que componentes no PlayerState e não no Pawn:
- Sobrevive ao despawn (morte/respawn)
- Spectator-friendly
- 1:1 com player (Pawn pode trocar: vehicle, mount, possession)
- Padrão MMO clássico (rathena, TrinityCore)
Adicionar novo módulo (Inventory, Skills, Guild) = 1 linha no
DefaultGame.ini, sem editar AZMMOPlayerState.cpp.
## HUD composite
- UZMMOHudWidget : UCommonActivatableWidget em ZMMO core (UI/InGame/)
- WBP_HUD em Content/ZMMO/UI/HUD/ herda UZMMOHudWidget
- HpSpBar (UZMMOHudHpSpWidget — sub-modulo ZMMOAttributes) embedded via
BindWidgetOptional. Sub-widgets futuros: PlayerInfo, Minimap, QuickBar,
Buffs, Chat, TargetInfo
- UZMMOHudHpSpWidget volta a ser UUserWidget puro (sub-widget, não root)
- UZMMOHudWidget::NativeOnActivated:
- SetVisibility(HitTestInvisible) — mouse passa direto pro Pawn
- BindToLocalPlayer: busca AttributeComponent no PC->PlayerState
- Subscreve OnAttributesChanged/OnHpSpChanged/OnLevelUp
- Propaga snapshots pra sub-widgets via HpSpBar->ApplySnapshot
- GetDesiredInputConfig override: ECommonInputMode::Game (sem cursor,
movimento livre). Menus override pra GameAndMenu/Menu.
## NetworkHandler refatorado (AttributeComponent agora no PlayerState)
- ZMMOAttributeNetworkHandler::FindComponentForEntity: busca via
GameState->PlayerArray (O(N_players)), não TActorIterator (O(N_actors))
- ZMMOAttributes.Build.cs += CommonUI, CommonInput
## EnsureRootLayout (fix crítico)
- OpenLevel invalida widgets no viewport ("InvalidateAllWidgets")
- RootLayout (criado pelo FrontEndFlow no boot) sobrevive como UObject
(LocalPlayerSubsystem) mas fica órfão fora do viewport
- Push do WBP_HUD no Stack_Game funcionava mas widget pai (RootLayout)
estava fora do viewport → invisível
- UUIFrontEndFlowSubsystem::EnsureRootLayout (público) recria via
UIManagerSubsystem (idempotente)
- UUIInGameFlowSubsystem::StartInGame chama EnsureRootLayout antes do push
## PlayerCharacter simplificado
- Removido AttributeComponent (vai pro PlayerState via Registry)
- Removido HudHpSpWidgetClass + spawn manual de HUD
- HandleLocalSpawnReady só faz seed do EntityId via PlayerState
- UI lifecycle agora é responsabilidade do AZMMOHUD::BeginPlay
## Verificação (smoke test)
PIE: Login -> CharSelect -> Lobby -> EnterWorld
↓
LogZMMO: AZMMOPlayerState ctor: 1 componente(s) no registry
LogZMMO: ZMMOPlayerState: componente registrado [0] ZMMOAttributeComponent
LogZMMO: FrontEndFlow::EnsureRootLayout: RootLayout recriado
LogZMMO: InGameFlow: state None -> Playing
LogZMMO: InGameFlow: tela Playing pushed em UI.Layer.Game (widget=WBP_HUD_C_0)
LogZMMO: ZMMOHudWidget activated (HpSpBar=WBP_HUD_HpSpBar_C_0)
↓
HUD aparece com Lv 5 / HP 10/200 / SP 5/20 (valores do DB)
Regen funciona (HP/SP enchem a cada 6s/8s)
Movimento normal (input flui pro Pawn)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/World.h"
|
||||
#include "EngineUtils.h" // TActorIterator
|
||||
#include "GameFramework/Actor.h"
|
||||
#include "GameFramework/GameStateBase.h"
|
||||
#include "GameFramework/PlayerState.h"
|
||||
#include "ZMMOAttributeComponent.h"
|
||||
#include "ZMMOAttributeTypes.h"
|
||||
#include "ZeusNetworkSubsystem.h"
|
||||
@@ -45,25 +45,22 @@ namespace
|
||||
return S;
|
||||
}
|
||||
|
||||
// V1 — resolve por EntityId varrendo atores do mundo procurando um que
|
||||
// tenha `UZMMOAttributeComponent`. Funciona para PIE com 1-2 players +
|
||||
// proxies. Otimizacao futura: usar `UZMMOWorldSubsystem::GetActorByEntityId`
|
||||
// (precisa expor) ou cache local de `EntityId -> UAttributeComponent*`.
|
||||
// Resolve `EntityId -> UZMMOAttributeComponent` via PlayerArray do GameState.
|
||||
// AttributeComponent agora vive no PlayerState (vide AZMMOPlayerState +
|
||||
// Component Registry em DefaultGame.ini). Itera apenas players (1 por
|
||||
// conexao), nao 1000+ atores — O(N_players).
|
||||
//
|
||||
// Convencao: `IZMMOEntityInterface::GetZMMOEntityId()` retorna o EntityId
|
||||
// autoritativo do servidor. Nao depender da interface aqui para manter
|
||||
// ZMMOAttributes sem `#include` do modulo ZMMO core — varremos `Tags`
|
||||
// alternativamente, mas o caminho mais simples e' a propria component
|
||||
// keys de `EntityId` no proprio componente (TODO Fase 2: armazenar
|
||||
// EntityId no componente quando snapshot chega).
|
||||
// Cada UZMMOAttributeComponent carrega seu proprio EntityId (seed em
|
||||
// AZMMOPlayerCharacter::HandleLocalSpawnReady ou no primeiro snapshot).
|
||||
UZMMOAttributeComponent* FindComponentForEntity(UWorld* World, int32 EntityId)
|
||||
{
|
||||
if (!World || EntityId == 0) { return nullptr; }
|
||||
for (TActorIterator<AActor> It(World); It; ++It)
|
||||
const AGameStateBase* GS = World->GetGameState();
|
||||
if (!GS) { return nullptr; }
|
||||
for (APlayerState* PS : GS->PlayerArray)
|
||||
{
|
||||
AActor* Actor = *It;
|
||||
if (!Actor) { continue; }
|
||||
UZMMOAttributeComponent* Comp = Actor->FindComponentByClass<UZMMOAttributeComponent>();
|
||||
if (!PS) { continue; }
|
||||
UZMMOAttributeComponent* Comp = PS->FindComponentByClass<UZMMOAttributeComponent>();
|
||||
if (!Comp) { continue; }
|
||||
if (Comp->GetSnapshot().EntityId == EntityId)
|
||||
{
|
||||
@@ -136,21 +133,23 @@ void UZMMOAttributeNetworkHandler::HandleAttributeSnapshotFull(const FZeusAttrib
|
||||
UZMMOAttributeComponent* Comp = FindComponentForEntity(World, Payload.EntityId);
|
||||
if (!Comp)
|
||||
{
|
||||
// V1 fallback: aplica ao primeiro UZMMOAttributeComponent encontrado
|
||||
// (player local). Isso resolve o caso do primeiro snapshot chegar
|
||||
// antes do `EntityId` estar registrado no componente (que so' acontece
|
||||
// apos o primeiro snapshot — chicken-and-egg). Apos o primeiro apply,
|
||||
// `Current.EntityId` ja' bate e o caminho normal funciona.
|
||||
for (TActorIterator<AActor> It(World); It; ++It)
|
||||
// Fallback chicken-and-egg: primeiro snapshot chega antes do EntityId
|
||||
// estar seeded no componente. Aplica ao primeiro componente do
|
||||
// PlayerArray que ainda tem EntityId=0. Apos o primeiro apply, o
|
||||
// caminho normal por lookup funciona.
|
||||
const AGameStateBase* GS = World->GetGameState();
|
||||
if (GS)
|
||||
{
|
||||
AActor* Actor = *It;
|
||||
if (!Actor) { continue; }
|
||||
Comp = Actor->FindComponentByClass<UZMMOAttributeComponent>();
|
||||
if (Comp && Comp->GetSnapshot().EntityId == 0)
|
||||
for (APlayerState* PS : GS->PlayerArray)
|
||||
{
|
||||
break;
|
||||
if (!PS) { continue; }
|
||||
UZMMOAttributeComponent* Candidate = PS->FindComponentByClass<UZMMOAttributeComponent>();
|
||||
if (Candidate && Candidate->GetSnapshot().EntityId == 0)
|
||||
{
|
||||
Comp = Candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Comp = nullptr;
|
||||
}
|
||||
}
|
||||
if (Comp)
|
||||
|
||||
@@ -3,99 +3,43 @@
|
||||
#include "Components/ProgressBar.h"
|
||||
#include "Components/TextBlock.h"
|
||||
#include "Internationalization/Text.h"
|
||||
#include "ZMMOAttributeComponent.h"
|
||||
|
||||
void UZMMOHudHpSpWidget::BindToAttributeComponent(UZMMOAttributeComponent* InComponent)
|
||||
{
|
||||
if (UZMMOAttributeComponent* Old = BoundComponent.Get())
|
||||
{
|
||||
Old->OnAttributesChanged.RemoveDynamic(this, &UZMMOHudHpSpWidget::HandleAttributesChanged);
|
||||
Old->OnHpSpChanged.RemoveDynamic(this, &UZMMOHudHpSpWidget::HandleHpSpChanged);
|
||||
}
|
||||
|
||||
BoundComponent = InComponent;
|
||||
|
||||
if (InComponent)
|
||||
{
|
||||
InComponent->OnAttributesChanged.AddDynamic(this, &UZMMOHudHpSpWidget::HandleAttributesChanged);
|
||||
InComponent->OnHpSpChanged.AddDynamic(this, &UZMMOHudHpSpWidget::HandleHpSpChanged);
|
||||
// Refresh imediato — caso o primeiro snapshot tenha chegado antes
|
||||
// do widget existir.
|
||||
HandleAttributesChanged(InComponent->GetSnapshot());
|
||||
}
|
||||
}
|
||||
|
||||
void UZMMOHudHpSpWidget::NativeDestruct()
|
||||
{
|
||||
if (UZMMOAttributeComponent* Old = BoundComponent.Get())
|
||||
{
|
||||
Old->OnAttributesChanged.RemoveDynamic(this, &UZMMOHudHpSpWidget::HandleAttributesChanged);
|
||||
Old->OnHpSpChanged.RemoveDynamic(this, &UZMMOHudHpSpWidget::HandleHpSpChanged);
|
||||
}
|
||||
BoundComponent.Reset();
|
||||
Super::NativeDestruct();
|
||||
}
|
||||
|
||||
void UZMMOHudHpSpWidget::HandleAttributesChanged(const FZMMOAttributesSnapshot& Snapshot)
|
||||
void UZMMOHudHpSpWidget::ApplySnapshot(const FZMMOAttributesSnapshot& Snapshot)
|
||||
{
|
||||
LastSnapshot = Snapshot;
|
||||
OnSnapshotApplied(Snapshot);
|
||||
}
|
||||
|
||||
void UZMMOHudHpSpWidget::HandleHpSpChanged(int32 Hp, int32 Sp)
|
||||
void UZMMOHudHpSpWidget::ApplyHpSp(int32 Hp, int32 MaxHp, int32 Sp, int32 MaxSp)
|
||||
{
|
||||
OnHpSpDelta(Hp, Sp);
|
||||
LastSnapshot.Hp = Hp;
|
||||
LastSnapshot.MaxHp = MaxHp;
|
||||
LastSnapshot.Sp = Sp;
|
||||
LastSnapshot.MaxSp = MaxSp;
|
||||
|
||||
if (HpBar)
|
||||
{
|
||||
HpBar->SetPercent(MaxHp > 0 ? static_cast<float>(Hp) / static_cast<float>(MaxHp) : 0.f);
|
||||
}
|
||||
if (SpBar)
|
||||
{
|
||||
SpBar->SetPercent(MaxSp > 0 ? static_cast<float>(Sp) / static_cast<float>(MaxSp) : 0.f);
|
||||
}
|
||||
if (HpText)
|
||||
{
|
||||
HpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), Hp, MaxHp)));
|
||||
}
|
||||
if (SpText)
|
||||
{
|
||||
SpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), Sp, MaxSp)));
|
||||
}
|
||||
}
|
||||
|
||||
void UZMMOHudHpSpWidget::OnSnapshotApplied_Implementation(const FZMMOAttributesSnapshot& Snapshot)
|
||||
{
|
||||
if (HpBar)
|
||||
{
|
||||
HpBar->SetPercent(Snapshot.MaxHp > 0
|
||||
? static_cast<float>(Snapshot.Hp) / static_cast<float>(Snapshot.MaxHp) : 0.f);
|
||||
}
|
||||
if (SpBar)
|
||||
{
|
||||
SpBar->SetPercent(Snapshot.MaxSp > 0
|
||||
? static_cast<float>(Snapshot.Sp) / static_cast<float>(Snapshot.MaxSp) : 0.f);
|
||||
}
|
||||
if (HpText)
|
||||
{
|
||||
HpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), Snapshot.Hp, Snapshot.MaxHp)));
|
||||
}
|
||||
if (SpText)
|
||||
{
|
||||
SpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), Snapshot.Sp, Snapshot.MaxSp)));
|
||||
}
|
||||
ApplyHpSp(Snapshot.Hp, Snapshot.MaxHp, Snapshot.Sp, Snapshot.MaxSp);
|
||||
if (LevelText)
|
||||
{
|
||||
LevelText->SetText(FText::FromString(FString::Printf(TEXT("Lv %d"), Snapshot.BaseLevel)));
|
||||
}
|
||||
}
|
||||
|
||||
void UZMMOHudHpSpWidget::OnHpSpDelta_Implementation(int32 Hp, int32 Sp)
|
||||
{
|
||||
// Atualiza apenas os campos vitais (evita refresh redundante de
|
||||
// stats/level que nao mudaram).
|
||||
if (UZMMOAttributeComponent* Comp = BoundComponent.Get())
|
||||
{
|
||||
const FZMMOAttributesSnapshot& Snap = Comp->GetSnapshot();
|
||||
if (HpBar)
|
||||
{
|
||||
HpBar->SetPercent(Snap.MaxHp > 0
|
||||
? static_cast<float>(Hp) / static_cast<float>(Snap.MaxHp) : 0.f);
|
||||
}
|
||||
if (SpBar)
|
||||
{
|
||||
SpBar->SetPercent(Snap.MaxSp > 0
|
||||
? static_cast<float>(Sp) / static_cast<float>(Snap.MaxSp) : 0.f);
|
||||
}
|
||||
if (HpText)
|
||||
{
|
||||
HpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), Hp, Snap.MaxHp)));
|
||||
}
|
||||
if (SpText)
|
||||
{
|
||||
SpText->SetText(FText::FromString(FString::Printf(TEXT("%d / %d"), Sp, Snap.MaxSp)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,27 @@
|
||||
|
||||
class UProgressBar;
|
||||
class UTextBlock;
|
||||
class UZMMOAttributeComponent;
|
||||
|
||||
/**
|
||||
* Widget base do HUD de atributos (HP/SP/level). Spawnada por
|
||||
* `AZMMOPlayerCharacter::BeginPlay` quando o pawn local virou autoritativo.
|
||||
* Sub-widget do HUD: barras de HP/SP + label de level. Sem auto-bind no
|
||||
* AttributeComponent — o parent (`UZMMOHudWidget`) propaga snapshots via
|
||||
* `ApplySnapshot` / `ApplyHpSpDelta` quando o player local recebe updates.
|
||||
*
|
||||
* Layout (cor, textos, ancoras) vem do WBP filho via UMG.
|
||||
* Comportamento (binding com AttributeComponent + refresh on delegate)
|
||||
* vive aqui em C++ — permite que outros modulos consumam esta API
|
||||
* sem precisar abrir o WBP. Sub-classe via Blueprint apenas para skin
|
||||
* visual e disposicao dos sub-widgets.
|
||||
* Composicao do HUD (ver `UZMMOHudWidget`):
|
||||
* WBP_HUD (UZMMOHudWidget root)
|
||||
* |-- HpSpBar (UZMMOHudHpSpWidget — este)
|
||||
* |-- PlayerInfo (futuro — nome/job/level)
|
||||
* |-- Minimap (futuro)
|
||||
* |-- QuickBar (futuro — hotkeys)
|
||||
* |-- Buffs (futuro — icones SC)
|
||||
* |-- Chat (futuro)
|
||||
* |-- TargetInfo (futuro — mob/player targetado)
|
||||
*
|
||||
* Convencao `meta=(BindWidget)`: o WBP filho DEVE ter widgets com os
|
||||
* mesmos nomes (HpBar, SpBar). `BindWidgetOptional` permite ao WBP
|
||||
* omitir o widget (ex.: HUD compacto sem texto numerico).
|
||||
* Cada sub-widget tem responsabilidade unica (SRP). O parent (UZMMOHudWidget)
|
||||
* faz binding com AttributeComponent e propaga deltas.
|
||||
*
|
||||
* Convencao `meta=(BindWidget)`: o WBP filho deve ter widgets com nomes
|
||||
* exatos (HpBar, SpBar). `BindWidgetOptional` permite omitir.
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable, BlueprintType)
|
||||
class ZMMOATTRIBUTES_API UZMMOHudHpSpWidget : public UUserWidget
|
||||
@@ -29,34 +35,24 @@ class ZMMOATTRIBUTES_API UZMMOHudHpSpWidget : public UUserWidget
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Liga o widget a um `UZMMOAttributeComponent`. Subscreve aos delegates
|
||||
* e faz um refresh imediato com o snapshot corrente. Idempotente —
|
||||
* chamar com outro componente desliga do anterior.
|
||||
* Se `InComponent` for nullptr, desliga sem religar.
|
||||
*/
|
||||
/** Atualiza todos os campos a partir do snapshot. Chamado pelo UZMMOHudWidget. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|HUD")
|
||||
void BindToAttributeComponent(UZMMOAttributeComponent* InComponent);
|
||||
void ApplySnapshot(const FZMMOAttributesSnapshot& Snapshot);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "Zeus|HUD")
|
||||
UZMMOAttributeComponent* GetBoundComponent() const { return BoundComponent.Get(); }
|
||||
/** Atualiza apenas HP/SP (sem mexer no level/text). Otimizacao do tick. */
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|HUD")
|
||||
void ApplyHpSp(int32 Hp, int32 MaxHp, int32 Sp, int32 MaxSp);
|
||||
|
||||
protected:
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
/**
|
||||
* Hook BlueprintNativeEvent para skin visual reagir a um snapshot novo
|
||||
* (cores, animacao de level up, etc.). Implementacao default em C++
|
||||
* apenas atualiza progress bars + textos via `BindWidget`/`BindWidgetOptional`.
|
||||
* (cores, animacao de level up, etc.). Impl default em C++ apenas
|
||||
* atualiza progress bars + textos via BindWidget/BindWidgetOptional.
|
||||
*/
|
||||
UFUNCTION(BlueprintNativeEvent, Category = "Zeus|HUD")
|
||||
void OnSnapshotApplied(const FZMMOAttributesSnapshot& Snapshot);
|
||||
virtual void OnSnapshotApplied_Implementation(const FZMMOAttributesSnapshot& Snapshot);
|
||||
|
||||
UFUNCTION(BlueprintNativeEvent, Category = "Zeus|HUD")
|
||||
void OnHpSpDelta(int32 Hp, int32 Sp);
|
||||
virtual void OnHpSpDelta_Implementation(int32 Hp, int32 Sp);
|
||||
|
||||
// === BindWidget — devem existir no WBP com EXATAMENTE estes nomes ===
|
||||
UPROPERTY(BlueprintReadOnly, meta = (BindWidget), Category = "Zeus|HUD")
|
||||
UProgressBar* HpBar = nullptr;
|
||||
@@ -74,12 +70,7 @@ protected:
|
||||
UTextBlock* LevelText = nullptr;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleAttributesChanged(const FZMMOAttributesSnapshot& Snapshot);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleHpSpChanged(int32 Hp, int32 Sp);
|
||||
|
||||
UPROPERTY()
|
||||
TWeakObjectPtr<UZMMOAttributeComponent> BoundComponent;
|
||||
/// Ultimo snapshot recebido (cache pra ApplyHpSp manter MaxHp/MaxSp). */
|
||||
UPROPERTY(Transient)
|
||||
FZMMOAttributesSnapshot LastSnapshot;
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ public class ZMMOAttributes : ModuleRules
|
||||
"Engine",
|
||||
"UMG",
|
||||
"Slate",
|
||||
"CommonUI",
|
||||
"CommonInput",
|
||||
"ZeusNetwork"
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "0.1.0",
|
||||
"side": "client",
|
||||
"dependencies": {
|
||||
"engine": ["Core", "CoreUObject", "Engine", "UMG", "Slate"],
|
||||
"engine": ["Core", "CoreUObject", "Engine", "UMG", "Slate", "CommonUI", "CommonInput"],
|
||||
"plugins": ["ZeusNetwork"],
|
||||
"modules": []
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user