feat(frontend): Fase 1 + 1.5 — ServerSelect dinamico + Lobby (chars por mundo)
ServerSelect (Fase 1): - UIServerSelectScreen_Base: subscribe OnRawMessage + C_WORLD_LIST_REQUEST - WBP_ServerCard instanciado dinamicamente em GridPanel CardContainer (2 cols) - UIServerCard_Base: SetFromEntry + OnCardPressed delegate + UI_Button_Master - Push real-time S_WORLD_STATUS_UPDATE (opcode 2062) atualiza card in-place - FZMMOWorldEntry struct, EZMMOWorldState enum no wire (offline/online/maint) - Sem hardcap de slots — cards usam toda largura via ColumnFill weights Lobby/CharSelect (Fase 1.5): - UIUserLobbyScreen_Base + WBP_UserLobby (WidgetSwitcher: lista + create) - C_CHAR_LIST_REQUEST filtrado por SelectedWorldId do FlowSubsystem - UICharCard_Base + WBP_CharCard com Select/Delete + Accept/Cancel quando delete agendado; Text_DeleteCountdown atualiza em tempo real (timer 1s) - UICharacterCreatePage_Base + WBP_CharacterCreate (Name + ComboBox class) - Handlers S_CHAR_SELECT_OK (handoff parse), CHAR_CREATE_OK/REJECT, CHAR_DELETE_ACK/ACCEPT_ACK/CANCEL_ACK (refresh lista) Auxiliares + fixes: - ARQUITETURA_SERVER_SELECT.md + ARQUITETURA_CHARACTER_MODEL.md - UIFrontEndFlowSubsystem: SelectedWorldId state + transicao Lobby - DA_FrontEndScreenSet: Lobby -> WBP_UserLobby_C - UICheckBox_Base + UICommonText_Base (componentes auxiliares) - BSB_Button_Transparent.Disabled DrawAs=IMAGE -> NoDraw (fix bg branco) - WBP_ServerCard layout: HBox para chips Region/Ping/Status, SizeBox 10x10 no Dot verde (anti-overflow); SelfHitTestInvisible em containers (hover)
This commit is contained in:
@@ -7,6 +7,10 @@
|
||||
|
||||
Última actualização: 2026-05-11.
|
||||
|
||||
> **Extensões da arquitetura** (documentos normativos paralelos, escopo específico):
|
||||
> - [`ARQUITETURA_SERVER_SELECT.md`](ARQUITETURA_SERVER_SELECT.md) — Server Select, lista de mundos, handoff CharServer↔WorldServer, multi-region, queue, Valkey. Roadmap em 7 fases.
|
||||
> - [`ARQUITETURA_CHARACTER_MODEL.md`](ARQUITETURA_CHARACTER_MODEL.md) — Stats primários (STR/AGI/VIT/INT/DEX/LUK), classes (Aprendiz → especializações), fórmulas de stats derivados (ATK/MATK/DEF/...), `jobs.yml` data-driven, stat allocation server-side. Espelha rathena.
|
||||
|
||||
> **Sumário de mudanças (2026-05-11):** adicionada secção de **temas sazonais
|
||||
> de UI** (`UI/Themes/`), com tutorial §4.7, regra de "no hard ref a textura
|
||||
> em widget" em §5 e política de activação em §1.10.
|
||||
@@ -211,7 +215,8 @@ Content/
|
||||
│ │ │ Chat, Party, Guild, Trade, Quest, Map,
|
||||
│ │ │ Shared, Icons
|
||||
│ │ ├── Shared/ ← WBPs reutilizáveis (UI_Button_Master,
|
||||
│ │ │ UI_Panel_Master, UI_Spinner_Master)
|
||||
│ │ │ UI_Panel_Master, UI_Spinner_Master,
|
||||
│ │ │ UI_CheckBox_Master, UI_Input_Master)
|
||||
│ │ ├── FrontEnd/ ← WBP_PrimaryGameLayout, DA_FrontEndScreenSet,
|
||||
│ │ │ telas refinadas (Boot/Login/… vindas do Forge)
|
||||
│ │ ├── Fonts/ ← Font_*/FF_* (Cinzel, Rajdhani)
|
||||
@@ -270,10 +275,15 @@ Source/ZMMO/
|
||||
│ └── UI/ ← runtime de UI (não é contrato de dados)
|
||||
│ ├── ZMMOThemeSubsystem.* ← resolução de tema
|
||||
│ ├── Widgets/ ← UUIButton_Base, UUIPanel_Base,
|
||||
│ │ UUISpinner_Base
|
||||
│ │ UUISpinner_Base, UUICheckBox_Base,
|
||||
│ │ UUIInput_Base (variantes Box/Outline/
|
||||
│ │ Underline/Search), UUICommonText_Base
|
||||
│ │ (EUITextRole → FUIStyle.Text)
|
||||
│ └── FrontEnd/ ← infra de navegação CommonUI:
|
||||
│ UUIActivatableScreen_Base,
|
||||
│ UUIBootScreen_Base (1ª tela),
|
||||
│ UUILoginScreen_Base (2ª tela),
|
||||
│ CharServerOpcodes.h (faixa 2000-2099),
|
||||
│ UUIPrimaryGameLayout_Base,
|
||||
│ UUIManagerSubsystem (LocalPlayer),
|
||||
│ UUIFrontEndFlowSubsystem (GameInstance),
|
||||
@@ -634,6 +644,15 @@ CharServer: libera o botão "Iniciar" no `OnConnected`; o clique chama
|
||||
`UUIFrontEndFlowSubsystem::RequestEnterLogin()` → estado Login. Tela nunca
|
||||
segura hard-ref de asset de tema (cores via `FUIStyle`; §5).
|
||||
|
||||
**Login:** a tela `UUILoginScreen_Base` autentica pelo MESMO WebSocket do
|
||||
CharServer enviando `C_CHAR_AUTH_REQUEST` (opcode 2000, ver
|
||||
`CharServerOpcodes.h`) com um token. Escopo atual = **dev**: token `dev:<n>`
|
||||
aceito pelo `StubTokenValidator` (senha ignorada no stub). `S_CHAR_AUTH_OK`
|
||||
→ `RequestEnterServerSelect()` → estado ServerSelect; `S_CHAR_AUTH_REJECT`
|
||||
→ mensagem de erro na própria tela. Auth real (HTTP `/auth/login` + JWT)
|
||||
fica para tarefa dedicada. "Voltar" → `RequestBack()` (Login→Boot, CharServer
|
||||
permanece conectado).
|
||||
|
||||
1. Prototipar a tela em HTML/CSS em `Tools/Templates/MMO_Widget/` (ou usar os
|
||||
templates já existentes).
|
||||
2. Rodar o Zeus UMG Forge (HTML→UMG). Saída crua: `Content/AutoCreated/<batch>/`.
|
||||
|
||||
891
ARQUITETURA_CHARACTER_MODEL.md
Normal file
891
ARQUITETURA_CHARACTER_MODEL.md
Normal file
@@ -0,0 +1,891 @@
|
||||
# Arquitetura: Character Model — Stats, Jobs, Fórmulas
|
||||
|
||||
> **Status:** documento normativo paralelo ao `ARQUITETURA.md`. Define como o modelo de personagem do ZMMO funciona — atributos, classes, derivações, autoridade. Espelha as decisões do `.bases/rathena/rathena-master` mas adaptado ao stack TS + UE5.7 do Zeus.
|
||||
|
||||
> **Escopo:** persistência + autoridade + onde tudo é calculado. **NÃO cobre** balanceamento de gameplay (decisão de game design — vai num doc de design separado).
|
||||
|
||||
## Context
|
||||
|
||||
O ZMMO usa modelo de atributos estilo Ragnarok Online — 6 stats primários que o jogador aloca pontos (STR/AGI/VIT/INT/DEX/LUK), com stats derivados calculados a partir deles + level + classe + equip. Jogador nasce **Aprendiz** (Novice) e especializa em outras classes ao chegar a critérios (job level + quest de mudança).
|
||||
|
||||
Este doc fixa:
|
||||
1. O que persiste no MySQL (CharServer) vs o que vive em memória (WorldServer).
|
||||
2. Como classes são definidas (data-driven, não tabela DB).
|
||||
3. Como atributos primários geram derivados (fórmulas espelhando rathena).
|
||||
4. Onde acontece o cálculo (autoridade).
|
||||
5. Como fluxos de criação/alocação/level up funcionam end-to-end.
|
||||
|
||||
---
|
||||
|
||||
## 1. Princípios
|
||||
|
||||
### 1.1 Persistência flat — TUDO na tabela `characters`
|
||||
|
||||
Padrão da indústria emuladora (rathena, TrinityCore, AzerothCore, MaNGOS). Ver `.bases/rathena/rathena-master/sql-files/main.sql:209-296`.
|
||||
|
||||
- ✅ 1 query lê o char completo (spawn, list, transfer).
|
||||
- ✅ Writeback é 1 UPDATE única.
|
||||
- ❌ Sem entidade `character_stats` separada (1:1 FK seria JOIN inútil — stats não são reutilizáveis entre chars, é relacionamento estritamente 1:1).
|
||||
|
||||
### 1.2 Stats primários persistem; derivados são sempre recalculados
|
||||
|
||||
| Categoria | Persiste no MySQL | Recalcula em runtime (WorldServer) |
|
||||
|---|---|---|
|
||||
| **Primários** (STR/AGI/VIT/INT/DEX/LUK) | ✅ | — |
|
||||
| **Progressão** (base_level, base_exp, job_level, job_exp) | ✅ | — |
|
||||
| **Pool atual** (hp, sp) | ✅ | — |
|
||||
| **Pool máximo** (max_hp, max_sp) | ✅ (cache) | Recalcula em level up / equip change |
|
||||
| **Pontos não-gastos** (status_point, skill_point) | ✅ | — |
|
||||
| **Class id** | ✅ | — |
|
||||
| **Moeda** (zeny) | ✅ | — |
|
||||
| **Derivados** (ATK, MATK, DEF, MDEF, hit, flee, crit, aspd) | ❌ NUNCA | ✅ Toda hora via `StatusCalc` |
|
||||
| **Regen rates** (hp_regen, sp_regen) | ❌ | ✅ |
|
||||
| **Buffs/debuffs ativos** | ❌ | ✅ (efêmero) |
|
||||
|
||||
**Por que NUNCA persistir derivados:** mudou um equip → derivado obsoleto. Aplicou buff → obsoleto. Cresceu de level → obsoleto. Salvar deriva inválida toda hora — bug fest silenciosa. Padrão rathena: `status_calc_pc_` em `.bases/rathena/rathena-master/src/map/status.cpp:4996` recalcula a `struct status_data` inteira a cada evento relevante.
|
||||
|
||||
### 1.3 Classes (jobs) são data-driven — arquivo YAML, não tabela DB
|
||||
|
||||
Padrão rathena: `db/re/job_stats.yml` carregado no boot. Hot-reloadable.
|
||||
|
||||
- ✅ Versionado no git (mudança de balanceamento = commit auditável)
|
||||
- ✅ Sem SELECT repetido em todo spawn
|
||||
- ✅ Game designer edita arquivo, não roda SQL
|
||||
|
||||
**No Zeus:** `Server/ZeusCharServer/data/jobs.yml` (carregado no boot do WorldServer e do CharServer). `characters.class_id` referencia lógica, não FK física.
|
||||
|
||||
### 1.4 Autoridade
|
||||
|
||||
| Componente | Quem decide | Quem persiste |
|
||||
|---|---|---|
|
||||
| Criar char (stats iniciais, class=Aprendiz) | CharServer (valida) | CharServer → MySQL |
|
||||
| Stat allocation (gastar `status_point` em STR/etc.) | CharServer **ou** WorldServer | CharServer → MySQL (via writeback) |
|
||||
| Subir de level / ganhar EXP | WorldServer (kill mob, quest) | WorldServer → writeback → CharServer → MySQL |
|
||||
| Mudar classe (Aprendiz → Espadachim) | WorldServer (cumpre quest) | WorldServer → writeback → CharServer → MySQL |
|
||||
| Recalcular ATK/MATK/DEF/etc. | WorldServer (memória, toda hora) | NÃO persiste |
|
||||
| Casting de skill | WorldServer | NÃO persiste (efêmero) |
|
||||
| Resultado de skill (HP perdido, item dropado) | WorldServer | WorldServer → writeback |
|
||||
|
||||
**Regra:** o estado "definitivo" do char vive no MySQL do CharServer. O WorldServer só **simula** com cópia em memória + writeback periódico (60s) e em eventos críticos (level up, item raro, logout).
|
||||
|
||||
---
|
||||
|
||||
## 2. Stats primários
|
||||
|
||||
### 2.1 Os 6 stats clássicos
|
||||
|
||||
| Stat | Nome PT-BR | Influencia diretamente |
|
||||
|---|---|---|
|
||||
| **STR** | Força | ATK (físico melee), peso carregável |
|
||||
| **AGI** | Agilidade | ASPD (velocidade de ataque), Flee (esquiva) |
|
||||
| **VIT** | Vitalidade | MaxHP, DEF (defesa física), resist status |
|
||||
| **INT** | Inteligência | MaxSP, MATK (mágico), MDEF |
|
||||
| **DEX** | Destreza | Hit (precisão), ATK (à distância — arco/arma de DEX), cast time |
|
||||
| **LUK** | Sorte | CRIT (crítico), Perfect Dodge, drop rate raros |
|
||||
|
||||
Persistidos como `SMALLINT UNSIGNED` (0-65535), default 1, cap configurável (rathena default 99 pré-renewal, 130 renewal — Zeus pode escolher).
|
||||
|
||||
### 2.2 Stats primários adicionais (opcional, futuro)
|
||||
|
||||
Rathena Renewal adiciona 6 "trait stats" para classes 4ª: POW, STA, WIS, SPL, CON, CRT. **Não escopo da Fase 1** — adicionar quando classes 4ª entrarem (Fase de gameplay avançado).
|
||||
|
||||
### 2.3 Cost de alocar +1 stat
|
||||
|
||||
Padrão rathena: `cost = (stat_atual - 1) / 10 + 2`.
|
||||
|
||||
```
|
||||
str=1 → custo de +1 stat = (0/10)+2 = 2 pontos
|
||||
str=10 → custo = (9/10)+2 = 2
|
||||
str=11 → custo = (10/10)+2 = 3
|
||||
str=20 → custo = (19/10)+2 = 3
|
||||
str=21 → custo = 4
|
||||
...
|
||||
str=99 → custo = (98/10)+2 = 11
|
||||
```
|
||||
|
||||
Validação **SEMPRE server-side**:
|
||||
```ts
|
||||
const cost = Math.floor((current - 1) / 10) + 2;
|
||||
if (char.status_point < cost) reject('NotEnoughPoints');
|
||||
UPDATE characters SET str = str+1, status_point = status_point - cost;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Stats derivados (calculados em runtime no WorldServer)
|
||||
|
||||
Lista mínima a implementar. Fórmulas espelham rathena (`status_calc_misc`, `status_calc_pc_sub`, `status_base_atk` em `src/map/status.cpp`).
|
||||
|
||||
### 3.1 ATK físico (base, sem equip)
|
||||
|
||||
Renewal (Zeus alvo):
|
||||
```
|
||||
base_atk = floor(str + (str/10)^2 + dex/5 + luk/3 + base_level/4)
|
||||
```
|
||||
|
||||
Para armas de DEX (arco, instrumento, chicote, armas de fogo): troca STR ↔ DEX no cálculo principal.
|
||||
|
||||
Total = `base_atk + weapon_atk + refine_bonus + cards_atk + status_atk_buffs`.
|
||||
|
||||
Ref: `.bases/rathena/rathena-master/src/map/status.cpp:2424` (`status_base_atk`).
|
||||
|
||||
### 3.2 MATK (mágico)
|
||||
|
||||
```
|
||||
matk_min = floor(int + int/7^2 + dex/5 + luk/3 + base_level/4)
|
||||
matk_max = floor(int + int/5^2 + dex/5 + luk/3 + base_level/4)
|
||||
```
|
||||
|
||||
### 3.3 DEF (defesa física)
|
||||
|
||||
```
|
||||
def2 = floor(vit + (vit/2)^2/30)
|
||||
def_total = def2 + def_from_equip + def_from_refine
|
||||
```
|
||||
|
||||
Renewal usa "soft DEF + hard DEF" — definir convenção no roadmap.
|
||||
|
||||
### 3.4 MDEF (defesa mágica)
|
||||
|
||||
```
|
||||
mdef2 = floor(int + vit/5 + dex/5)
|
||||
mdef_total = mdef2 + mdef_from_equip
|
||||
```
|
||||
|
||||
### 3.5 HIT (precisão)
|
||||
|
||||
```
|
||||
hit = base_level + dex + luk/3 + skill_bonus + equip_bonus
|
||||
```
|
||||
|
||||
### 3.6 FLEE (esquiva)
|
||||
|
||||
```
|
||||
flee = base_level + agi + luk/5 + skill_bonus + equip_bonus
|
||||
```
|
||||
|
||||
### 3.7 CRIT (crítico)
|
||||
|
||||
```
|
||||
crit = 1 + luk/3 + skill_bonus + equip_bonus
|
||||
```
|
||||
|
||||
(Em rathena multiplica por 10 internamente para resolução; converte na display.)
|
||||
|
||||
### 3.8 ASPD (velocidade de ataque)
|
||||
|
||||
```
|
||||
aspd = base_aspd_da_arma - (agi + dex/4) * fator_de_classe
|
||||
```
|
||||
|
||||
`base_aspd_da_arma` vem do `jobs.yml` (cada job tem array `BaseASPD` por tipo de arma).
|
||||
|
||||
### 3.9 MaxHP / MaxSP — três camadas (cuidado para não persistir bônus)
|
||||
|
||||
Esta é a parte mais delicada. **Há três camadas** de MaxHP/MaxSP, cada uma com regra diferente:
|
||||
|
||||
| Camada | Onde vive | Quando recalcula | Persiste no DB? |
|
||||
|---|---|---|---|
|
||||
| **1. Base** | `characters.max_hp` / `max_sp` (cache) | Level up, job change, stat allocation (VIT/INT permanentes) | ✅ Sim |
|
||||
| **2. Bônus de equipamento** (anel VIT+5, armor MaxHP+200) | WorldServer em memória | Equip / desequip de item | ❌ Nunca — recalcula do `character_inventory` no spawn |
|
||||
| **3. Bônus de buff** (skill "Endure" +100 MaxHP por 60s) | WorldServer em memória (`status_change`) | Buff start / expire | ❌ Nunca — efêmero, expira sozinho |
|
||||
|
||||
**MaxHP efetivo (mostrado pro player) = camada 1 + camada 2 + camada 3.** Sempre calculado em runtime, nunca persistido.
|
||||
|
||||
**Fórmula da camada 1 (base):**
|
||||
```
|
||||
max_hp_base = floor(jobs.yml[class].HpFactor * base_level + jobs.yml[class].HpIncrease) * (1 + vit_base/100)
|
||||
max_sp_base = floor(jobs.yml[class].SpFactor * base_level + jobs.yml[class].SpIncrease) * (1 + int_base/100)
|
||||
```
|
||||
|
||||
Note: usa `vit_base` (`characters.vit`), **não** `vit_efetivo` (com bônus de equip). Bônus de equip não recalcula o cache base — entra como camada 2 separada.
|
||||
|
||||
**Triggers que escrevem `characters.max_hp` (camada 1):**
|
||||
- ✅ Level up (`base_level += 1`)
|
||||
- ✅ Job change (`HpFactor` novo)
|
||||
- ✅ Stat allocation permanente (`vit_base += 1`)
|
||||
|
||||
**Triggers que NÃO escrevem `characters.max_hp` (só atualizam camadas 2/3 em memória):**
|
||||
- ❌ Equip de item com VIT+5 ou MaxHP+200
|
||||
- ❌ Desequip de item
|
||||
- ❌ Buff aplicado (skill, food, gravação)
|
||||
- ❌ Buff expirou
|
||||
|
||||
**Por que essa separação importa (cenário de bug se misturar):**
|
||||
|
||||
Suponha que você persistisse `max_hp` total (base + equip + buff):
|
||||
1. Player equipa anel VIT+5 → bônus +50 MaxHP → `max_hp=1500` salvo no DB (era 1450 base).
|
||||
2. WorldServer crasha.
|
||||
3. Spawn: lê `max_hp=1500` do DB.
|
||||
4. Recarrega equip do inventory mas o WorldServer "esqueceu" qual era o bônus do anel — só vê 1500.
|
||||
5. Player desequipa o anel → bug: continua com 1500.
|
||||
|
||||
Pior com buff: writeback periódico salva `max_hp` durante buff ativo → buff expira mas DB tem o valor inflado → spawn dá HP zombie.
|
||||
|
||||
**Padrão rathena** ([src/map/status.cpp:status_calc_pc_](.bases/rathena/rathena-master/src/map/status.cpp)): salva só camada 1. Camadas 2 e 3 são sempre recalculadas do equip + buffs ativos no spawn. Buffs não persistem (morrem no logout).
|
||||
|
||||
**Estrutura em memória no WorldServer (template):**
|
||||
```cpp
|
||||
struct CharRuntimeStatus {
|
||||
// --- Camada 1 (do DB; persistido) ---
|
||||
uint32 max_hp_base;
|
||||
uint32 max_sp_base;
|
||||
uint16 str_base, agi_base, vit_base, int_base, dex_base, luk_base;
|
||||
|
||||
// --- Camada 2 (do inventory; não persiste) ---
|
||||
int32 equip_max_hp_bonus, equip_max_sp_bonus;
|
||||
int16 equip_str_bonus, equip_agi_bonus, /*...*/ equip_luk_bonus;
|
||||
|
||||
// --- Camada 3 (de status_change ativos; não persiste) ---
|
||||
int32 buff_max_hp_bonus, buff_max_sp_bonus;
|
||||
int16 buff_str_bonus, /*...*/;
|
||||
|
||||
// --- Computados on-demand (camada 1 + 2 + 3) ---
|
||||
uint32 EffectiveMaxHp() const { return max_hp_base + equip_max_hp_bonus + buff_max_hp_bonus; }
|
||||
uint16 EffectiveVit() const { return vit_base + equip_vit_bonus + buff_vit_bonus; }
|
||||
// ... análogo pros outros stats
|
||||
};
|
||||
```
|
||||
|
||||
Cliente sempre vê o efetivo (`S_CHAR_HP_UPDATE { effective_hp, effective_max_hp }`). Cliente nunca vê o base nem os bônus separados — só a soma final.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema `jobs.yml`
|
||||
|
||||
Arquivo: `Server/ZeusCharServer/data/jobs.yml`.
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
jobs:
|
||||
- id: 0
|
||||
name: Novice # nome técnico (inglês, estável)
|
||||
display_name_ptbr: Aprendiz
|
||||
parent_job: null
|
||||
max_base_level: 99
|
||||
max_job_level: 10
|
||||
hp_factor: 35 # rathena Novice: ~35
|
||||
hp_increase: 0
|
||||
sp_factor: 10
|
||||
sp_increase: 0
|
||||
max_weight: 20000
|
||||
base_aspd:
|
||||
bare: 2000
|
||||
dagger: 1900
|
||||
sword: 2000
|
||||
starting_hp: 40
|
||||
starting_sp: 11
|
||||
starting_status_points: 0
|
||||
starting_skill_points: 0
|
||||
allowed_weapons: [bare, dagger]
|
||||
skills:
|
||||
- id: NV_BASIC
|
||||
max_level: 10
|
||||
- id: NV_FIRST_AID
|
||||
max_level: 1
|
||||
|
||||
- id: 1
|
||||
name: Swordman
|
||||
display_name_ptbr: Espadachim
|
||||
parent_job: Novice # precisa ter sido Novice antes
|
||||
requirements:
|
||||
base_level: 1 # 1 (na verdade qualquer; Ragnarok exige job_level 10 Novice — definir)
|
||||
job_level: 10 # do Novice
|
||||
quest: "swordman_test" # quest id no questdb
|
||||
max_base_level: 99
|
||||
max_job_level: 50
|
||||
hp_factor: 70
|
||||
hp_increase: 200
|
||||
sp_factor: 20
|
||||
sp_increase: 200
|
||||
bonus_stats: # auto-stats ao subir job level
|
||||
- job_level: 2
|
||||
str: 1
|
||||
- job_level: 6
|
||||
vit: 1
|
||||
# ...
|
||||
allowed_weapons: [bare, dagger, sword, two_hand_sword, axe]
|
||||
skills:
|
||||
- id: SM_BASH
|
||||
max_level: 10
|
||||
# ...
|
||||
|
||||
# Mago, Arqueiro, Mercador, Ladrão, Acólito...
|
||||
```
|
||||
|
||||
**Notas:**
|
||||
- IDs estáveis (não mude) — `characters.class_id` referencia direto.
|
||||
- Mudar campo `hp_factor` ou `bonus_stats` rebalanceia o jogo retroativamente. **OK** — RuneScape/PoE/MMOs fazem isso o tempo todo.
|
||||
- Hot-reload: endpoint admin `POST /admin/jobs/reload` recarrega `jobs.yml` em runtime sem restart (Fase 6+; Fase 1 só load no boot).
|
||||
|
||||
---
|
||||
|
||||
## 5. Fluxos
|
||||
|
||||
### 5.1 Criação de personagem (Aprendiz)
|
||||
|
||||
```
|
||||
Cliente → C_CHAR_CREATE { name, world_id, class_id=NOVICE, appearance }
|
||||
|
||||
CharServer:
|
||||
1. Validar (name único globalmente, world_id existe, slot disponível, class_id=NOVICE — não pode criar direto em outra classe)
|
||||
2. Lookup jobs.yml[NOVICE] → { starting_hp, starting_sp, hp_factor, sp_factor, ... }
|
||||
3. INSERT characters (
|
||||
account_id, world_id, slot, name, class_id=NOVICE,
|
||||
base_level=1, base_exp=0, job_level=1, job_exp=0,
|
||||
str=1, agi=1, vit=1, int=1, dex=1, luk=1,
|
||||
hp=starting_hp, max_hp=starting_hp, sp=starting_sp, max_sp=starting_sp,
|
||||
status_point=0, skill_point=0,
|
||||
zeny=0, appearance,
|
||||
map_name="zmmo_starting_village", pos_x=..., pos_y=..., pos_z=..., yaw_deg=0
|
||||
)
|
||||
4. → S_CHAR_CREATE_OK
|
||||
```
|
||||
|
||||
**Nota:** o player **sempre** nasce Aprendiz. Outras classes só via job change in-game (Fase pós-Fase 3).
|
||||
|
||||
### 5.2 Stat allocation (após level up)
|
||||
|
||||
```
|
||||
Cliente → C_CHAR_STAT_ALLOC { stat: "str", amount: 1 }
|
||||
|
||||
CharServer (ou WorldServer; ver §6 — autoridade):
|
||||
1. Lookup char no cache/DB
|
||||
2. cost = floor((char.str - 1) / 10) + 2
|
||||
3. amount * cost ≤ char.status_point? else REJECT('NotEnoughPoints')
|
||||
4. char.str + amount > MAX_STAT(jobs.yml[class].max_stat)? else REJECT('StatCapped')
|
||||
5. UPDATE characters SET str = str + 1, status_point = status_point - cost, version = version + 1
|
||||
6. WorldServer: status_calc_pc(char) → recalcula derivados → manda S_CHAR_STAT_UPDATE pro cliente
|
||||
```
|
||||
|
||||
### 5.3 Level up
|
||||
|
||||
```
|
||||
WorldServer (em kill mob / complete quest):
|
||||
1. char.base_exp += mob.exp_reward
|
||||
2. enquanto char.base_exp >= exp_table[char.base_level + 1]:
|
||||
char.base_exp -= exp_table[char.base_level + 1]
|
||||
char.base_level += 1
|
||||
char.status_point += status_per_level(char.base_level) // ex.: 3 base + level/4
|
||||
max_hp = recalc(char, jobs.yml)
|
||||
max_sp = recalc(char, jobs.yml)
|
||||
char.hp = max_hp // heal full no level up (padrão Ragnarok)
|
||||
char.sp = max_sp
|
||||
3. POST /interserver/characters/{id}/checkpoint (writeback imediato — level up é crítico)
|
||||
4. Enviar S_CHAR_LEVEL_UP pro cliente
|
||||
```
|
||||
|
||||
Mesmo padrão pra job_exp/job_level (com skill_point em vez de status_point).
|
||||
|
||||
### 5.4 Job change (Aprendiz → Espadachim, etc.)
|
||||
|
||||
```
|
||||
WorldServer (player cumpriu quest):
|
||||
1. Validar requirements: jobs.yml[Swordman].requirements.{base_level, job_level, quest}
|
||||
2. char.class_id = SWORDMAN
|
||||
3. char.job_level = 1
|
||||
4. char.job_exp = 0
|
||||
5. (Opcional Ragnarok-style: status_point NÃO zera; player mantém stats alocados)
|
||||
6. recalc max_hp/max_sp (novo HpFactor/SpFactor)
|
||||
7. POST /interserver/characters/{id}/checkpoint
|
||||
8. S_CHAR_JOB_CHANGE
|
||||
```
|
||||
|
||||
### 5.5 Logout — writeback final
|
||||
|
||||
```
|
||||
WorldServer:
|
||||
POST /interserver/characters/{id}/checkpoint {
|
||||
str, agi, vit, int, dex, luk,
|
||||
base_level, base_exp, job_level, job_exp,
|
||||
hp, max_hp, sp, max_sp,
|
||||
status_point, skill_point, zeny,
|
||||
map_name, pos_x, pos_y, pos_z, yaw_deg,
|
||||
appearance,
|
||||
version: current_version
|
||||
}
|
||||
```
|
||||
|
||||
Conflict (409) → recarrega + merge + retry.
|
||||
|
||||
---
|
||||
|
||||
## 5.6 Loadouts (build switching) — Modelo A: equip + skills, stats permanentes
|
||||
|
||||
### Premissa
|
||||
|
||||
**Stats primários (str/agi/vit/int/dex/luk) são permanentes** uma vez alocados — padrão Ragnarok clássico. Permitir trocar stats via loadout livre destruiria o significado de cada ponto investido.
|
||||
|
||||
Loadouts no ZMMO guardam **apenas equip e skills** — exatamente o padrão FFXIV Gearset / WoW Equipment Manager. Cobre o caso de uso real (alternar entre setup PvE/PvP/farm sem reequipar manualmente todos os slots), sem virar free respec.
|
||||
|
||||
### Stat reset (separado de loadouts)
|
||||
|
||||
Reset de stats é uma operação **rara e cara**, fora do sistema de loadouts. Caminhos previstos (a decidir no game design):
|
||||
- Item raro "Reset Stone" (drop de boss / cash shop).
|
||||
- NPC específico que cobra moeda (zeny alto ou moeda especial).
|
||||
- Privilégio VIP (limite mensal).
|
||||
|
||||
Mecânica: zera `str/agi/vit/int/dex/luk` aos valores base do job, e converte tudo investido em `status_point` (player realoca do zero). WorldServer endpoint dedicado, audit log obrigatório.
|
||||
|
||||
### Schema `character_loadouts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE character_loadouts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
character_id BIGINT UNSIGNED NOT NULL,
|
||||
slot_index TINYINT UNSIGNED NOT NULL, -- 0..N-1 (N = MAX_LOADOUTS, ex.: 10)
|
||||
name VARCHAR(32) NOT NULL, -- "Tank", "DPS PvE", "PvP Build"
|
||||
|
||||
-- Snapshot de equip (referência a instâncias específicas no inventory,
|
||||
-- FK pra character_inventory.id que entra em frente futura).
|
||||
equip_head_id BIGINT UNSIGNED NULL,
|
||||
equip_body_id BIGINT UNSIGNED NULL,
|
||||
equip_weapon_id BIGINT UNSIGNED NULL,
|
||||
equip_shield_id BIGINT UNSIGNED NULL,
|
||||
equip_garment_id BIGINT UNSIGNED NULL,
|
||||
equip_footgear_id BIGINT UNSIGNED NULL,
|
||||
equip_acc_left_id BIGINT UNSIGNED NULL,
|
||||
equip_acc_right_id BIGINT UNSIGNED NULL,
|
||||
equip_head_top_id BIGINT UNSIGNED NULL,
|
||||
equip_head_mid_id BIGINT UNSIGNED NULL,
|
||||
equip_head_bottom_id BIGINT UNSIGNED NULL,
|
||||
-- (Slots espelham o padrão rathena: weapon, shield, garment, footgear,
|
||||
-- acc esquerda, acc direita, head top/mid/bottom)
|
||||
|
||||
-- Snapshot de hotbar de skills (JSON: { slot_index: skill_id })
|
||||
skill_hotbar JSON NULL,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY uniq_char_slot (character_id, slot_index),
|
||||
INDEX idx_loadouts_character (character_id)
|
||||
);
|
||||
```
|
||||
|
||||
**Notas:**
|
||||
- **`characters` continua flat e inalterado** — não há `active_loadout_id`. Stats + equip atuais vivem em `characters`. Loadouts são *snapshots salvos*, não "estado ativo".
|
||||
- Não inclui stats — modelo A. Loadout não é respec.
|
||||
- Não inclui skills aprendidas (essas ficam em `character_skills` quando a feature entrar) — só a `skill_hotbar` (quais skills estão nas teclas de atalho).
|
||||
- Referências de equip apontam pra **instâncias específicas** do item (`character_inventory.id`), não pra item template id genérico. Se o player vendeu o item depois de salvar a build, o slot vira NULL na hora de aplicar + warning UI.
|
||||
|
||||
### Fluxos de loadout
|
||||
|
||||
**Salvar build atual como preset:**
|
||||
```
|
||||
Cliente → C_LOADOUT_SAVE { slot_index, name }
|
||||
WorldServer:
|
||||
Pega equip atual + skill_hotbar do char em memória
|
||||
INSERT/UPDATE character_loadouts SET ...
|
||||
POST /interserver/characters/{id}/loadouts/save (writeback imediato)
|
||||
S_LOADOUT_SAVED { slot_index }
|
||||
```
|
||||
|
||||
**Aplicar loadout (trocar equip + hotbar):**
|
||||
```
|
||||
Cliente → C_LOADOUT_APPLY { slot_index }
|
||||
WorldServer:
|
||||
SELECT * FROM character_loadouts WHERE character_id=$id AND slot_index=$slot
|
||||
Para cada equip_*_id no loadout:
|
||||
Verifica se item ainda existe em character_inventory + pertence ao char
|
||||
Se sim: equipa
|
||||
Se não: slot vira NULL + retorna warning lista de slots "perdidos"
|
||||
Aplica skill_hotbar
|
||||
Recalcula stats derivados (StatusCalc)
|
||||
S_LOADOUT_APPLIED { applied_slot, missing_slots? }
|
||||
```
|
||||
|
||||
**Listar loadouts (CharSelect ou in-world):**
|
||||
```
|
||||
Cliente → C_LOADOUT_LIST
|
||||
WorldServer:
|
||||
SELECT slot_index, name FROM character_loadouts WHERE character_id=$id ORDER BY slot_index
|
||||
S_LOADOUT_LIST [{ slot_index, name, summary }]
|
||||
```
|
||||
|
||||
**Deletar loadout:**
|
||||
```
|
||||
Cliente → C_LOADOUT_DELETE { slot_index }
|
||||
DELETE FROM character_loadouts WHERE character_id=$id AND slot_index=$slot
|
||||
```
|
||||
|
||||
### Comportamento na seleção de char (CharSelect)
|
||||
|
||||
Aplicar loadout só funciona **in-world**, não na CharSelect — porque depende do inventory carregado, contexto de combate, etc. CharSelect só lista qual foi a última usada (estado UX no cliente). WorldServer aplica o loadout ativo automaticamente no spawn se cliente indicou via parâmetro do C_CHAR_SELECT (opcional — pode também simplesmente carregar o último equip salvo em `characters` direto).
|
||||
|
||||
### Autoridade
|
||||
|
||||
Mesmo princípio dos stats: **WorldServer é dono**. Cliente envia comando, WorldServer valida + aplica + persiste via canal C.
|
||||
|
||||
### Eager loading no spawn (resposta à sua dúvida específica)
|
||||
|
||||
**Pergunta original:** "ao pegar Character retorna também Stats — fica mais fácil".
|
||||
|
||||
Como decidimos modelo A (stats permanentes em `characters`, sem entidade separada), **não há eager loading de stats** — já vêm na mesma row.
|
||||
|
||||
Para loadouts:
|
||||
- **CharServer (TypeORM):** anota relacionamento com `{ eager: true }` ou faz `.relations(['loadouts'])` em queries específicas. Suporte nativo.
|
||||
- **WorldServer (`ZeusPersistence` C++):** hoje o `Repository<T>` é single-table. **Recomendação imediata:** 2 queries separadas no service de spawn (`Repo<Character>.Find(id)` + `Repo<CharacterLoadout>.FindWhere(character_id=id)`). Performance idêntica a JOIN para single char. Simples.
|
||||
- **Roadmap (`ZeusPersistence` v2):** estender com `Repository<T>::Include<R>()` (estilo EF Core). Vale o trabalho quando aparecer a 3ª relação eager (loadouts + inventory + skills + buffs). Adiar até lá.
|
||||
|
||||
### Roadmap
|
||||
|
||||
Loadouts entram em **frente própria de gameplay**, depois das Fases A-C do Character Model e da feature de inventory. Não bloqueia Fase 1 do ServerSelect — schema é puro additive (não muda `characters`).
|
||||
|
||||
---
|
||||
|
||||
## 6. Autoridade — onde stat allocation acontece?
|
||||
|
||||
Duas escolhas defensáveis:
|
||||
|
||||
| Onde | Vantagens | Desvantagens |
|
||||
|---|---|---|
|
||||
| **CharServer** (recebe `C_CHAR_STAT_ALLOC` direto do cliente) | Simples; muda DB direto sem RPC | Cliente in-world tem que avisar WorldServer "stats mudaram" → recalcular |
|
||||
| **WorldServer** (cliente sempre fala com mundo) | WorldServer já tem char na memória, recalcula imediato | Stat allocation só funciona logged in (sem alocação fora do mundo) |
|
||||
|
||||
**Recomendação:** **WorldServer.** Razões:
|
||||
1. Stat allocation só faz sentido in-world (precisa de UI de char, contexto de combate).
|
||||
2. WorldServer tem stats derivados em memória — UPDATE de STR força recálculo imediato.
|
||||
3. CharServer não precisa entender game logic de cost (`(s-1)/10+2`) — fica isolado.
|
||||
4. Padrão rathena: map-server (= WorldServer) faz `pc_statusup` ([rathena/src/map/pc.cpp](.bases/rathena/rathena-master/src/map/pc.cpp)).
|
||||
|
||||
Fluxo: cliente envia opcode UDP pro WorldServer → valida + UPDATE em memória + writeback diferido (não imediato — junto com o próximo checkpoint periódico de 60s). Level up é exceção (writeback imediato).
|
||||
|
||||
---
|
||||
|
||||
## 7. Anti-cheat e validação server-side
|
||||
|
||||
**Toda** mudança de stat passa por validação no servidor:
|
||||
|
||||
- Cliente envia `C_CHAR_STAT_ALLOC { stat, amount }` → servidor valida + faz, nunca cliente diz "novo valor".
|
||||
- HP/SP nunca vêm do cliente — só do WorldServer (autoridade).
|
||||
- EXP é WorldServer who-says.
|
||||
- Zeny: transações sempre server-side (trade, NPC shop, drop).
|
||||
|
||||
Cliente apenas **renderiza** o estado que o servidor empurra (`S_CHAR_STAT_UPDATE`, `S_CHAR_HP_UPDATE`, etc.).
|
||||
|
||||
Audit log entries (Fase 3 do ServerSelect doc): toda mudança de zeny>1000, level up, mudança de classe, item raro recebido → `POST /interserver/audit`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Roadmap de implementação
|
||||
|
||||
### Fase A — Schema + criação de char Aprendiz (parte da Fase 1 do ServerSelect)
|
||||
|
||||
- Schema `characters` com todos os campos (já especificado no `ARQUITETURA_SERVER_SELECT.md`).
|
||||
- `jobs.yml` apenas com `Novice` (Aprendiz) — outras classes ficam pra B.
|
||||
- CharServer carrega `jobs.yml` no boot.
|
||||
- `C_CHAR_CREATE` valida `class_id == NOVICE` e usa defaults do yml.
|
||||
- Cliente UI: tela de criação com nome + appearance (sem stat allocation aqui — Aprendiz nasce com todos os stats=1).
|
||||
|
||||
### Fase B — Classes adicionais + job change
|
||||
|
||||
- `jobs.yml` expande para Espadachim, Mago, Arqueiro, Mercador, Ladrão, Acólito (6 first jobs clássicos do Ragnarok).
|
||||
- Quest de mudança de classe (mínimo: NPC simples; quest system real fica pra C).
|
||||
- WorldServer endpoint pra mudar class.
|
||||
|
||||
### Fase C — Stat allocation + status_point/skill_point granting
|
||||
|
||||
- WorldServer recebe `C_CHAR_STAT_ALLOC` UDP opcode.
|
||||
- Validação cost = `(s-1)/10+2`.
|
||||
- `S_CHAR_STAT_UPDATE` push pro cliente.
|
||||
- Skill point granting tied to job_level up.
|
||||
|
||||
### Fase D — Stats derivados completos + StatusCalc framework
|
||||
|
||||
- Implementar `StatusCalc::ComputeAll(char, equip, buffs)` no WorldServer C++.
|
||||
- ATK/MATK/DEF/MDEF/hit/flee/crit/aspd.
|
||||
- Push de update no equip change, buff aplicado, level up.
|
||||
|
||||
### Fase E — Classes 2ª (advanced) e além
|
||||
|
||||
- Especialização (Knight, Wizard, Hunter, etc. — segunda promoção do Ragnarok).
|
||||
- Eventualmente classes 3ª (Renewal) e trait stats (POW/STA/WIS/SPL/CON/CRT).
|
||||
|
||||
### Fase F — Loadouts (build switching, Modelo A)
|
||||
|
||||
**Pré-requisito:** inventory implementado (FK de `character_loadouts.equip_*_id` aponta pra `character_inventory.id`).
|
||||
|
||||
- Schema `character_loadouts` (já especificado em §5.6).
|
||||
- WorldServer endpoints: `C_LOADOUT_SAVE/APPLY/LIST/DELETE`.
|
||||
- Cliente UI: tela "Builds" com até N slots, botões salvar/aplicar/renomear/deletar, preview de equip.
|
||||
- Audit log em apply (Fase 3 do ServerSelect doc).
|
||||
|
||||
### Fase G — Stat reset (item / NPC / VIP)
|
||||
|
||||
- Endpoint `C_CHAR_STAT_RESET` no WorldServer.
|
||||
- Validação: tem o item de reset? Tem o zeny do NPC? Tem privilégio VIP + cooldown?
|
||||
- Lógica: zera str/agi/vit/int/dex/luk pros base do job; converte total investido em `status_point`.
|
||||
- Audit log obrigatório (write-heavy, sensível a abuso).
|
||||
|
||||
**A se conecta com a Fase 1 do ServerSelect.** B-G são frentes próprias de gameplay, fora do escopo de network/handoff.
|
||||
|
||||
---
|
||||
|
||||
## 9. Decisões consolidadas
|
||||
|
||||
1. **Stats primários flat** na tabela `characters` (str/agi/vit/int/dex/luk + level/exp + hp/sp/max_hp/max_sp + status_point/skill_point + zeny + class_id). **Sem entidade separada.**
|
||||
2. **Stats derivados NUNCA persistidos** — sempre recalculados em runtime no WorldServer (`StatusCalc::ComputeAll`).
|
||||
3. **Jobs data-driven** via `Server/ZeusCharServer/data/jobs.yml` (versionado no git, hot-reloadable). `characters.class_id` é lookup lógico.
|
||||
4. **Player nasce Aprendiz** (`class_id=NOVICE`); especialização via quest in-game (Fase B).
|
||||
5. **Stat allocation acontece no WorldServer** (não CharServer). Validação `cost = (s-1)/10 + 2` server-side.
|
||||
6. **Anti-cheat:** toda mudança de estado é server-authoritative. Cliente apenas renderiza.
|
||||
7. **Fórmulas espelham rathena** (`status_calc_pc_` em `src/map/status.cpp:4996`) — adapta valores pra balanceamento Zeus depois.
|
||||
8. **MaxHP/MaxSP são persistidos como cache** (recalcula em level up / equip change, salva pra evitar recalcular no spawn).
|
||||
9. **Writeback granular:** stat allocation = writeback diferido (junto do checkpoint 60s); level up + job change = writeback imediato.
|
||||
10. **Loadouts/build switching (Modelo A — Ragnarok clássico):** tabela `character_loadouts` separada (1:N com `characters`). Guarda **apenas equip + skill_hotbar**, NÃO stats. Stats permanecem permanentes; reset só via item raro / NPC / VIP (operação dedicada, fora do sistema de loadouts). Aplicar loadout = trocar equip e hotbar, server-side, in-world only.
|
||||
11. **Sem `active_loadout_id` em `characters`:** estado vivo permanece em `characters` (flat). Loadouts são snapshots salvos pra reaplicar sob demanda.
|
||||
12. **Eager loading:** TypeORM já suporta. No `ZeusPersistence` C++ usa 2 queries separadas por enquanto; `Include<R>()` fluent fica como roadmap quando houver 3+ relações eager.
|
||||
|
||||
---
|
||||
|
||||
## 10. Lições do rathena — o que adotar e o que evitar
|
||||
|
||||
Análise direta do código do rathena (versão master atual em `.bases/rathena/rathena-master/`). Lista o que vale levar pro modelo moderno do Zeus e o que é legado a evitar.
|
||||
|
||||
### 10.1 Três estruturas de status, não duas
|
||||
|
||||
Rathena tem **3 structs separadas** convivendo em `map_session_data` ([src/map/pc.hpp:382-388](.bases/rathena/rathena-master/src/map/pc.hpp)):
|
||||
|
||||
```cpp
|
||||
class map_session_data : public block_list {
|
||||
...
|
||||
struct mmo_charstatus status; // (1) PERSISTED — SQL <-> RAM
|
||||
struct status_data base_status; // (2) BASE — RAM only
|
||||
struct status_data battle_status; // (3) BATTLE — RAM only
|
||||
status_change sc; // (Buffs/debuffs ativos)
|
||||
...
|
||||
};
|
||||
```
|
||||
|
||||
| Camada | Conteúdo | Persiste no DB? | Recalcula quando |
|
||||
|---|---|---|---|
|
||||
| **(1) `mmo_charstatus status`** (PERSISTED) | Stats primários permanentes, level/exp, HP/SP atuais, equip slots, skill levels aprendidos, zeny, pos | ✅ Sim — espelha SQL `char` table | Em writeback periódico (a partir do battle_status) |
|
||||
| **(2) `status_data base_status`** (BASE) | Stats primários + bônus de equip + bônus de skills passivas. ATK/MATK/DEF/etc. SEM buffs ativos. | ❌ Não | Equip change, level up, mudança de skill, mudança de job, stat allocation. Via `status_calc_pc_` ([src/map/status.cpp:4996](.bases/rathena/rathena-master/src/map/status.cpp)) |
|
||||
| **(3) `status_data battle_status`** (BATTLE) | base_status **+** todos os buffs/debuffs ativos (`status_change`). É o valor "final" usado em combat e mostrado pro cliente. | ❌ Não | Cada vez que um buff aplica/expira, cada hit (debuffs proc). Via `status_calc_bl_main` ([src/map/status.cpp:5836](.bases/rathena/rathena-master/src/map/status.cpp)) |
|
||||
|
||||
**Por que três e não duas:** separar equip+passivas (camada 2, muda raro) de buffs (camada 3, muda toda hora) permite recalcular APENAS camada 3 quando um buff entra/sai. Sem isso, todo buff força recálculo completo de equipamento — caro em PvP/raids com muitos buffs simultâneos.
|
||||
|
||||
**Adaptação Zeus C++ (WorldServer):**
|
||||
|
||||
```cpp
|
||||
struct CharRuntimeState {
|
||||
PersistedStatus persisted; // copia do MySQL via ZeusPersistence (camada 1)
|
||||
BaseStatusData base_status; // calculado de persisted + equip + passivas (camada 2)
|
||||
BaseStatusData battle_status; // base_status + status_change (camada 3)
|
||||
StatusChangeContainer sc; // lista de buffs ativos com expire_tick
|
||||
};
|
||||
```
|
||||
|
||||
`battle_status` é o que vai pro `S_CHAR_STAT_UPDATE` pro cliente. Cliente nunca vê `base_status` nem `persisted` direto.
|
||||
|
||||
### 10.2 Recompute total > delta incremental
|
||||
|
||||
Rathena **zera `base_status` inteiro** (`memset` em status.cpp:3816) e recompila tudo do zero a cada `status_calc_pc_`. Não tenta "subtrair o equip antigo, somar o novo" — pull-not-push.
|
||||
|
||||
```cpp
|
||||
// rathena status.cpp:3816 (simplificado)
|
||||
memset(&base_status->max_hp, 0, sizeof(struct status_data) - sizeof(hp/sp/ap));
|
||||
// reaplica defaults do job
|
||||
// reaplica equipped items
|
||||
// reaplica skills passivas
|
||||
// recalcula derivados
|
||||
```
|
||||
|
||||
**Por que:** delta incremental cria drift sutil. Um bug de "esqueci subtrair na hora de desequipar" e o stat fica inflado pra sempre. Recompute total é O(N) por evento mas N é pequeno (~10 slots de equip + ~30 skills passivas) e elimina bug class inteira.
|
||||
|
||||
**Adotar:** mesma estratégia no `WorldServer::StatusCalc::RecomputeBase(char)`. Em C++ moderno usar `base_status = BaseStatusData{};` (move-assign do default) em vez de `memset` (porque structs com membros não-trivially-copyable quebram).
|
||||
|
||||
### 10.3 HP/SP atual nunca é zerado no recálculo
|
||||
|
||||
Truque clássico do rathena (status.cpp:3816): o `memset` começa em `&base_status->max_hp`, **pulando** os primeiros membros (`hp`, `sp`, `ap`). HP atual sobrevive ao recálculo de base_status.
|
||||
|
||||
**Razão:** equipar um anel não pode resetar o HP do char pra zero. Mas pode mudar `max_hp` — se `max_hp` aumentou, HP continua o atual; se diminuiu, capa em `min(hp, max_hp)`.
|
||||
|
||||
**Adotar:** ordenar membros do struct em C++ pra ter HP/SP/AP no topo, e adotar regra `RecomputeBase()` sem tocar nesses 3. Ou explicitamente preservar:
|
||||
|
||||
```cpp
|
||||
const auto saved_hp = base_status.hp, saved_sp = base_status.sp;
|
||||
base_status = BaseStatusData{};
|
||||
base_status.hp = saved_hp; base_status.sp = saved_sp;
|
||||
ApplyJobDefaults(); ApplyEquip(); ApplyPassives(); ComputeDerived();
|
||||
base_status.hp = std::min(base_status.hp, base_status.max_hp); // cap
|
||||
```
|
||||
|
||||
### 10.4 Recursion guard obrigatório no recálculo
|
||||
|
||||
Rathena ([status.cpp:3756-3764](.bases/rathena/rathena-master/src/map/status.cpp)):
|
||||
|
||||
```cpp
|
||||
static int32 calculating = 0;
|
||||
if (++calculating > 10) return -1;
|
||||
// ... recálculo
|
||||
--calculating;
|
||||
```
|
||||
|
||||
**Razão:** recálculo de stat pode trigger skill que aplica buff que dispara recálculo. Sem guard = stack overflow em corner cases (combos de auto-cast, item triggers cascading).
|
||||
|
||||
**Adotar:** flag thread-local `bool recalculating = false` ou contador. Se já está em recálculo, retornar erro/skip em vez de reentrar. Importante mesmo em arquiteturas modernas — buffs reativos a hits criam loops circulares fácil.
|
||||
|
||||
### 10.5 Recalc parcial via bitflags (otimização avançada)
|
||||
|
||||
Rathena tem ([status.hpp](.bases/rathena/rathena-master/src/map/status.hpp)):
|
||||
|
||||
```cpp
|
||||
enum scb_flag {
|
||||
SCB_NONE = 0, SCB_BASE, SCB_STR, SCB_AGI, /*...*/, SCB_WATK, SCB_BATK,
|
||||
SCB_MAX
|
||||
};
|
||||
status_calc_bl_main(bl, std::bitset<SCB_MAX>{ SCB_STR | SCB_WATK });
|
||||
```
|
||||
|
||||
Quando só STR muda (e nada mais), recalcula apenas WATK derivado (que depende de STR). Performance no PvP de alto nível.
|
||||
|
||||
**Adotar:** **NÃO na Fase 1**. Implementar como otimização depois quando profile mostrar `RecomputeBase` aparecendo no top do flame chart. Pra MMOs com <1000 players concurrent, recompute total é OK.
|
||||
|
||||
### 10.6 Buffs em estrutura separada com expiração por timer
|
||||
|
||||
Rathena `status_change` ([src/map/status.hpp](.bases/rathena/rathena-master/src/map/status.hpp)):
|
||||
|
||||
```cpp
|
||||
struct status_change_entry {
|
||||
int32 timer; // handle do scheduler
|
||||
int32 val1-4; // payload (varia por tipo)
|
||||
t_tick tick; // duração total
|
||||
};
|
||||
struct status_change {
|
||||
status_change_entry *data[SC_MAX]; // indexado por SCType
|
||||
};
|
||||
```
|
||||
|
||||
Cada SC (status change) tem timer próprio que dispara `status_change_end` quando expira. Engine de timer global gerencia. **Buffs NUNCA persistem em SQL** — morrem em logout (regra mais comum) ou DC (alguns servers preservam alguns SC em rejoin rápido).
|
||||
|
||||
**Adotar:**
|
||||
```cpp
|
||||
struct StatusChangeEntry {
|
||||
StatusChangeType type;
|
||||
int32 stack_value; // SC ID + payload
|
||||
TickT expire_at; // absolute deadline
|
||||
};
|
||||
class StatusChangeContainer {
|
||||
std::unordered_map<StatusChangeType, StatusChangeEntry> active;
|
||||
void Tick(TickT now) { /* expira os terminados, dispara recalc se necessário */ }
|
||||
};
|
||||
```
|
||||
|
||||
Buffs morrem em logout — padrão da indústria. Exceções (food buffs, gravações permanentes, equip-while-stationary) tratadas via cases especiais no game design depois.
|
||||
|
||||
### 10.7 Item bonus data-driven, não hardcoded
|
||||
|
||||
Rathena tem `pc_bonus` ([src/map/pc.cpp](.bases/rathena/rathena-master/src/map/pc.cpp)) com switch case gigante:
|
||||
|
||||
```cpp
|
||||
void pc_bonus(map_session_data *sd, int32 type, int32 val) {
|
||||
switch (type) {
|
||||
case SP_STR: sd->base_status.str += val; break;
|
||||
case SP_AGI: sd->base_status.agi += val; break;
|
||||
case SP_MAXHP: sd->base_status.max_hp += val; break;
|
||||
// ... ~200 cases
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
E `item_db.yml` declara em YAML quais bonuses cada item dá:
|
||||
```yaml
|
||||
- Id: 1109
|
||||
Name: Sword
|
||||
Type: Weapon
|
||||
Script: |
|
||||
bonus bAtk, 25;
|
||||
bonus bStr, 1;
|
||||
```
|
||||
|
||||
**O bom:** items são data-driven (designer edita YAML, não código).
|
||||
**O ruim:** switch case com 200 cases é horror de manter. Bug em um case afeta um stat só, difícil de testar.
|
||||
|
||||
**Adotar (versão moderna):**
|
||||
```cpp
|
||||
using BonusHandler = void(*)(BaseStatusData&, int32 val);
|
||||
inline const std::unordered_map<BonusType, BonusHandler> kBonusHandlers = {
|
||||
{ BonusType::Str, [](auto& s, int32 v){ s.str += v; } },
|
||||
{ BonusType::Agi, [](auto& s, int32 v){ s.agi += v; } },
|
||||
{ BonusType::MaxHp, [](auto& s, int32 v){ s.max_hp += v; } },
|
||||
// ...
|
||||
};
|
||||
void ApplyBonus(BaseStatusData& s, BonusType type, int32 val) {
|
||||
if (auto it = kBonusHandlers.find(type); it != kBonusHandlers.end()) {
|
||||
it->second(s, val);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Mesma capacidade data-driven, sem switch gigante. Testes unitários ficam triviais (testa cada handler isolado).
|
||||
|
||||
### 10.8 Save state separado do battle state
|
||||
|
||||
Rathena periodicamente faz `pc_makesavestatus(sd)` que **copia** `sd->battle_status.hp/sp/ap` → `sd->status.hp/sp/ap`. Só depois isso vai pro CharServer/SQL.
|
||||
|
||||
**Razão:** `battle_status` tem cap inflado por buff (max_hp +500 de buff). `status.hp` deve refletir o valor real que faz sentido persistir (HP atual, capa de `status.max_hp` que é o base puro sem buff).
|
||||
|
||||
**Adotar:** writeback NUNCA salva valores afetados por buff ativo. Save HP é o `min(battle.hp, base.max_hp)`. Save MaxHP é `base.max_hp`. Garante que log out + log in com buff expirado não dá HP zombie.
|
||||
|
||||
### 10.9 O que NÃO levar do rathena
|
||||
|
||||
Práticas que são legado e dão mais trabalho do que valem em projeto novo:
|
||||
|
||||
| Anti-pattern | Por que evitar | Alternativa moderna |
|
||||
|---|---|---|
|
||||
| **`battle_config` global com 500+ flags** | Estado mutável global, difícil de testar, conflito em multi-tenant | Struct `BalanceConfig` injetada por DI; YAML de balance versionado |
|
||||
| **Arrays C fixos (`MAX_SKILL`, `MAX_INVENTORY`)** | Crash se exceder; memória desperdiçada se vazio | `std::vector` ou `std::array` com bounds-check em debug |
|
||||
| **`map_session_data : block_list`** (herda) | Mistura "dados do char" com "ator com posição no mundo" | Separar `CharacterData` (POD) de `WorldActor` (componente de posição/movimento) |
|
||||
| **Job class em bitfield** (`JOBL_BABY`, `MAPID_SUMMONER`) | Decisão de packet do RO original. Manutenção horrível | `enum class JobId` + `struct JobTraits { bool is_baby; bool can_summon; }` data-driven em `jobs.yml` |
|
||||
| **Recursive call preemption por contador** | Workaround pra event model mal-formado | ECS / actor model não-reentrante; quando reentrância é necessária, fila de eventos diferida |
|
||||
| **`pc_calc_skilltree` muta skill tree do char** | Mistura "skills aprendidas" (persistente) com "efeitos passivos ativos" (derivado) | Separar `LearnedSkills` (no DB) de `ActivePassives` (calculado em memória) |
|
||||
| **`status_change->data[SC_MAX]` array linkedlist** | Lookup linear, memória pré-alocada | `std::unordered_map<SCType, SCEntry>` ou `boost::flat_map` para hot path |
|
||||
| **Hot-reload via `@reloadbattleconf`** in-game commands | Não tem versionamento, vira PvP-zoeira "ajustei config no live" | Hot-reload via endpoint admin autenticado + audit log obrigatório |
|
||||
| **SQL queries em string puro** (sem ORM) no map server | SQL injection era real; manutenção horrível | ZeusPersistence (já fizemos) ou TypeORM no CharServer |
|
||||
| **Globals com função inicializadora (`do_init_status`)** | Side effects no static init | Singleton com lifetime explícito, `StatusEngine engine; engine.Init(config);` |
|
||||
|
||||
### 10.10 Resumo do que vai pro Zeus
|
||||
|
||||
**Adotar:**
|
||||
1. **3 structs**: `PersistedStatus` (SQL), `BaseStatusData` (RAM, equip+passivas), `BattleStatusData` (RAM, +buffs).
|
||||
2. **Recompute total** em camadas 2 e 3 — não delta incremental.
|
||||
3. **HP/SP atual preservado** em recálculo de base.
|
||||
4. **Recursion guard** no `RecomputeBase`/`RecomputeBattle`.
|
||||
5. **`StatusChange` container** com expiração por timer; buffs nunca persistem.
|
||||
6. **Bonus handler table** (não switch case).
|
||||
7. **Save state separado** do battle state no writeback.
|
||||
|
||||
**Adiar (otimização):**
|
||||
- Recalc parcial via bitflags (vale com >1000 concurrent).
|
||||
|
||||
**Evitar:**
|
||||
- battle_config global; arrays C fixos; herança block_list; job em bits; SQL crú no map server; hot-reload sem audit.
|
||||
|
||||
---
|
||||
|
||||
## 11. Não cobertos aqui
|
||||
|
||||
Por design, estes temas têm docs próprios (futuros):
|
||||
|
||||
- **Inventory e items** (slots, refine, cards, sockets) — `ARQUITETURA_INVENTORY.md` (a criar).
|
||||
- **Skill system** (skill tree, cooldowns, animations) — `ARQUITETURA_SKILLS.md` (a criar).
|
||||
- **Quests** — `ARQUITETURA_QUESTS.md` (a criar).
|
||||
- **Combat resolution** (damage formula, element table, race modifier) — `ARQUITETURA_COMBAT.md` (a criar).
|
||||
- **Game balance** (números concretos: quanto STR dá quanto ATK em level N) — `DESIGN_BALANCE.md` (decisão de game design, não arquitetura).
|
||||
- **Party/guild** — `ARQUITETURA_SOCIAL.md` (a criar).
|
||||
|
||||
Este documento só fixa **estrutura e autoridade** — o que vai onde, quem decide o quê.
|
||||
|
||||
---
|
||||
|
||||
## Referências
|
||||
|
||||
- rathena `db/re/job_stats.yml` — schema base de jobs ([.bases/rathena/rathena-master/db/re/job_stats.yml](Server/.bases/rathena/rathena-master/db/re/job_stats.yml))
|
||||
- rathena `src/map/status.cpp:status_calc_pc_` — recálculo de status ([linha 4996](Server/.bases/rathena/rathena-master/src/map/status.cpp))
|
||||
- rathena `src/map/status.cpp:status_base_atk` — fórmula de ATK base ([linha 2424](Server/.bases/rathena/rathena-master/src/map/status.cpp))
|
||||
- rathena `sql-files/main.sql:209-296` — tabela `char` flat ([main.sql](Server/.bases/rathena/rathena-master/sql-files/main.sql))
|
||||
- rathena `src/map/pc.cpp:pc_statusup` — server-side stat allocation
|
||||
- [`ARQUITETURA_SERVER_SELECT.md`](ARQUITETURA_SERVER_SELECT.md) — schema de `characters` e fluxos de criação no contexto de mundos
|
||||
593
ARQUITETURA_SERVER_SELECT.md
Normal file
593
ARQUITETURA_SERVER_SELECT.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# Arquitetura: Server Select + Mundos + Handoff CharServer↔WorldServer
|
||||
|
||||
> **Status:** plano de implementação + extensão da arquitetura. Quando aprovado, este documento vira `Clients/ZMMO/Docs/ARQUITETURA_SERVER_SELECT.md` (ou path equivalente em `Server/`) — é referência permanente, não plano descartável.
|
||||
|
||||
## Context
|
||||
|
||||
Hoje a tela de Server Select carrega após o login mas é um mock visual sem dados reais:
|
||||
|
||||
- `worldHost`/`worldPort` no `S_CHAR_SELECT_OK` está hardcoded em `127.0.0.1:27777`.
|
||||
- `handoffToken` é gerado aleatório no CharServer mas **nada valida no WorldServer**.
|
||||
- Não existe tabela `worlds` no schema; não existe opcode pra listar mundos.
|
||||
- `characters` não tem `world_id` — qualquer char joga em qualquer mundo.
|
||||
- WorldServer não reporta população/status; CharServer não sabe quem está online onde.
|
||||
|
||||
Esta frente desenha a arquitetura "de verdade": múltiplos mundos cadastrados, criação de personagem amarrada ao mundo escolhido, handoff seguro com ticket validado, e uma base que escale pra multi-node + multi-region. Cobre desde o desenho conceitual até execução em fases pequenas e verificáveis.
|
||||
|
||||
---
|
||||
|
||||
## Princípios arquiteturais (consolidado, após autocrítica)
|
||||
|
||||
### 1. Separação de responsabilidades
|
||||
|
||||
| Responsabilidade | Quem é dono | Justificativa |
|
||||
|---|---|---|
|
||||
| **Identidade do jogador** (account, JWT, sessões) | CharServer + MySQL global | Auth precisa ser único cross-region. JWT habilita multi-node sem state replication. |
|
||||
| **Persistência de personagens** (chars, inventário, level) | CharServer + MySQL global | Habilita transfer, cross-world social, char list multi-mundo. Padrão AccelByte/FFXIV. |
|
||||
| **Estado vivo de mundos** (pop, queue, status) | WorldServer → Valkey (regional) | Source-of-truth do mundo é quem hospeda o mundo. CharServer só lê e propaga. |
|
||||
| **Simulação autoritativa** (movimento, combate, IA) | WorldServer C++ Unreal | Latência baixa + tick fixo + autoridade contra cheat. |
|
||||
| **Tickets de handoff** (single-use, TTL curto) | CharServer emite, Valkey armazena, WorldServer consome | Padrão GameLift: lobby decide quem entra. |
|
||||
|
||||
### 2. Quatro canais de comunicação (sem sobreposição)
|
||||
|
||||
CharServer já roda dois listeners simultâneos (`buildServer` em `core/server.ts`): **uWebSockets.js** na porta 7100 (cliente Unreal, path `/zeus`) e **Fastify HTTPS** na porta 7101 (control plane). É a base do padrão AccelByte: WSS pra push real-time pro cliente, HTTPS pra chamadas pontuais e tráfego servidor-a-servidor.
|
||||
|
||||
| Canal | Quem fala com quem | Protocolo | Onde está | Uso |
|
||||
|---|---|---|---|---|
|
||||
| **A** | Cliente ↔ CharServer | WebSocket (WSS) binário Zeus | `wss://...:7100/zeus` | Login, char list/create/select, lista de mundos, push de status, queue updates, chat (futuro) |
|
||||
| **B** | Cliente ↔ WorldServer | UDP binário Zeus | `UZeusNetworkSubsystem` ↔ `ZeusNet/UdpServer` | Handshake + gameplay autoritativo. Cliente apresenta ticket no `C_CONNECT_REQUEST` |
|
||||
| **C** | CharServer ↔ WorldServer | HTTPS REST (Fastify) | `:7101/interserver/*` | Claim, character RPC, audit, kick (mTLS na Fase 6) |
|
||||
| **D** | Admin/CLI ↔ CharServer | HTTPS REST + CLI direto MySQL | `:7101/admin/*` e CLI `npm run world` | CRUD de mundos, métricas, health |
|
||||
|
||||
**Regras invioláveis:**
|
||||
- Cliente Unreal **NUNCA** fala HTTP com o CharServer. Tudo é WSS via opcodes Zeus.
|
||||
- Cliente Unreal **NUNCA** consulta o WorldServer pra ver pop/status. Sempre via CharServer (canal A).
|
||||
- WorldServer **NUNCA** escreve direto no MySQL — sempre via canal C (validação + auditoria).
|
||||
|
||||
### 3. Pop tracking: três triggers combinados (não escolher um — usar todos)
|
||||
|
||||
WorldServer é o único que sabe a verdade sobre pop/queue/state. Reporta via **Valkey** (Fase 2+); na Fase 1 reporta via HTTP POST como fallback temporário.
|
||||
|
||||
| Trigger | O que faz | Frequência |
|
||||
|---|---|---|
|
||||
| **Event-driven** (player entrou/saiu) | `HINCRBY world:{id}:status pop ±1` + `PUBLISH world:events` | Imediato |
|
||||
| **State-change** (online↔maintenance↔offline) | `HSET world:{id}:status state=...` + `PUBLISH world:events` | Imediato |
|
||||
| **Periodic heartbeat** | `HSET world:{id}:status ...` + `PEXPIRE 30000` (reset TTL) | A cada 5s |
|
||||
|
||||
**Por que os três:** events dão real-time; periodic reconcilia se um PUBLISH foi perdido; TTL detecta world morto automaticamente em ~30s sem código extra.
|
||||
|
||||
### 4. Topologia geo-distribuída
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ MySQL Primary (writes globais) │
|
||||
│ ex.: us-east-1 ou Hetzner DE │
|
||||
└──────────┬─────────────────────────────┘
|
||||
│ async replication (<100ms)
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
read-replica read-replica read-replica
|
||||
(NA) (SA) (EU)
|
||||
│ │ │
|
||||
┌──────┴──────┐ ┌──────┴──────┐ ┌──────┴──────┐
|
||||
│ CharServer │ │ CharServer │ │ CharServer │
|
||||
│ node × N │ │ node × N │ │ node × N │
|
||||
│ Valkey (NA) │ │ Valkey (SA) │ │ Valkey (EU) │
|
||||
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||
na.zeusmmo… sa.zeusmmo… eu.zeusmmo…
|
||||
▲ ▲ ▲
|
||||
WorldServers WorldServers WorldServers
|
||||
na região NA na região SA na região EU
|
||||
▲ ▲ ▲
|
||||
Clientes NA Clientes SA Clientes EU
|
||||
(GeoDNS/CDN) (GeoDNS/CDN) (GeoDNS/CDN)
|
||||
```
|
||||
|
||||
**Regras:**
|
||||
1. WorldServer fala **apenas** com CharServer da sua região (`CharServerEndpoint` no `server.json`).
|
||||
2. Cliente conecta no DNS regional via GeoDNS. Não cruza região durante sessão.
|
||||
3. MySQL writes globais (primary central); reads na replica regional.
|
||||
4. Valkey é por-região (estado vivo é local — sem cross-region sync).
|
||||
5. Lista de mundos é regional por default (padrão FFXIV — cliente vê só os mundos da `account.region`).
|
||||
6. `ZEUS_REGION` env var em cada CharServer node; `region` no `worlds.region` deve bater com a região do CharServer que recebe o claim do WorldServer.
|
||||
|
||||
| Região | DNS público | MySQL replica | Valkey cluster |
|
||||
|---|---|---|---|
|
||||
| Brasil/SA | `sa.zeusmmo.com.br` | sa-east-1 | cluster SA |
|
||||
| North America | `na.zeusmmo.com.br` | us-east-1 | cluster NA |
|
||||
| Europe | `eu.zeusmmo.com.br` | eu-central-1 | cluster EU |
|
||||
| Asia | `as.zeusmmo.com.br` | ap-southeast-1 | cluster AS |
|
||||
|
||||
---
|
||||
|
||||
## Componentes-chave
|
||||
|
||||
### Identidade do mundo: admin-create + WorldServer-claim
|
||||
|
||||
Híbrido (estilo AccelByte Armada). Três alternativas e por que escolhemos esta:
|
||||
|
||||
| Padrão | Veredito |
|
||||
|---|---|
|
||||
| Auto-register em runtime | ✗ Restart cria ID novo, órfana chars com FK |
|
||||
| Pre-config puro (estilo WoW) | ✗ Sem prova de identidade — qualquer processo manda heartbeat falso |
|
||||
| **Admin-create + claim com secret** | ✓ ID estável + prova de posse + auditoria |
|
||||
|
||||
**Fluxo:**
|
||||
|
||||
1. Admin via CLI: `npm run world -- create --name=Aurora --host=... --port=... --region=br --cap=500` → CharServer gera `world_id` (UUID) + `world_secret` (32 bytes base64url). Output mostra os dois **uma vez**. `secret_hash` (Argon2id) é persistido em `worlds.secret_hash`.
|
||||
2. Admin copia `world_id` + `world_secret` pro `Config/server.json` do WorldServer.
|
||||
3. WorldServer no boot: `POST /interserver/worlds/{id}/claim { secret }`. CharServer valida Argon2id contra `secret_hash` + region match. Sucesso → `state=online`. Falha → `403` + WorldServer aborta startup.
|
||||
4. WorldServer começa heartbeat (Fase 1 HTTP; Fase 2+ Valkey direto).
|
||||
5. WorldServer crashado → TTL Valkey expira em 30s → `state=offline` automático.
|
||||
|
||||
### Ticket de handoff: opaque token single-use
|
||||
|
||||
**Hoje:** `handoffToken` é random sem validação. Inseguro.
|
||||
|
||||
**Padrão escolhido:** opaque token 32 bytes (crypto.randomBytes), **TTL 10s** (não 30 — janela menor pra replay), armazenado em Valkey, consumido via `GETDEL` no WorldServer (single-use).
|
||||
|
||||
```
|
||||
CharServer ao emitir:
|
||||
payload = { account_id, char_id, world_id, expires_at }
|
||||
SETEX ticket:{token} 10 <payload-json>
|
||||
INCR world:{world_id}:reserved ← reserva slot (anti-overbooking)
|
||||
responde S_CHAR_SELECT_OK(worldHost, worldPort, token)
|
||||
|
||||
Cliente:
|
||||
conecta UDP no World, envia C_CONNECT_REQUEST(nonce) + ticket
|
||||
|
||||
WorldServer:
|
||||
GETDEL ticket:{token} ← consome
|
||||
valida: world_id==EU? char_id válido? não expirou?
|
||||
se OK:
|
||||
DECR world:{world_id}:reserved ← libera reserva
|
||||
HINCRBY world:{world_id}:status pop 1
|
||||
PUBLISH world:events ...
|
||||
fetch char data via canal C → spawn
|
||||
se NOK:
|
||||
S_CONNECT_REJECT
|
||||
```
|
||||
|
||||
**Por que opaque + Valkey lookup (não JWT):** revogação trivial (DEL), single-use natural (GETDEL), payload menor no UDP (32 bytes vs ~200 do JWT). Round-trip Valkey de ~3-5ms é desprezível no handshake (acontece 1x por sessão de horas).
|
||||
|
||||
### Single sign-on enforcement (sem char duplicado in-world)
|
||||
|
||||
Sem isso, dois clientes podem entrar com mesmo char → item dup, corrupção. Acontece em **todo** MMO sem proteção.
|
||||
|
||||
```
|
||||
CharServer ao emitir ticket (antes do SETEX):
|
||||
active = GET char:active:{char_id}
|
||||
se active existe (account já está in-world em algum mundo):
|
||||
POST /interserver/worlds/{active.world_id}/kick { char_id, reason: "logged_in_elsewhere" }
|
||||
aguarda confirmação (com timeout 3s)
|
||||
se WorldServer não respondeu: DEL char:active:{char_id} forçado
|
||||
prossegue com ticket
|
||||
|
||||
WorldServer ao spawnar:
|
||||
SETEX char:active:{char_id} 120 '{"world_id":...,"node_id":...}'
|
||||
refresh a cada 60s (renova TTL)
|
||||
|
||||
WorldServer ao despawnar (logout/crash):
|
||||
DEL char:active:{char_id}
|
||||
```
|
||||
|
||||
### Concurrency control no writeback (sem silent corruption)
|
||||
|
||||
Cenário: WorldServer cacheou char na memória; admin via SQL muda level. Próximo writeback do WorldServer sobrescreve. Solução padrão = optimistic locking via `version`.
|
||||
|
||||
```sql
|
||||
characters: version INT UNSIGNED DEFAULT 0
|
||||
```
|
||||
|
||||
```
|
||||
WorldServer carrega char:
|
||||
GET /interserver/characters/{id} → guarda version=N
|
||||
|
||||
WorldServer writeback:
|
||||
POST /interserver/characters/{id}/checkpoint { pos, inv, ..., version: N }
|
||||
CharServer SQL:
|
||||
UPDATE characters SET ..., version=N+1 WHERE id=$id AND version=$N
|
||||
se affected_rows=0:
|
||||
409 Conflict
|
||||
WorldServer recarrega + faz merge + retry
|
||||
```
|
||||
|
||||
### Capacity com reserva (anti-overbooking)
|
||||
|
||||
`pop >= cap` ingênuo causa overbooking: 50 tickets emitidos não conectaram ainda; pop reporta 450/500; entra mais 50; quando todos conectarem → 501/500. Bug clássico de New World/WoW launch.
|
||||
|
||||
**Solução padrão:** ticket emitido **reserva slot** em Valkey:
|
||||
|
||||
```
|
||||
Capacidade real = pop + reserved < cap
|
||||
pop = HGET world:{id}:status pop
|
||||
reserved = GET world:{id}:reserved (decai com expiração natural)
|
||||
|
||||
Emit ticket:
|
||||
if pop + reserved >= cap: enfileira (Fase 4) ou reject WorldFull
|
||||
else: SETEX ticket:{token} 10 ... + INCR world:{id}:reserved
|
||||
|
||||
WorldServer consome ticket:
|
||||
DECR world:{id}:reserved + INCR pop
|
||||
|
||||
Ticket expira sem ser consumido (timeout):
|
||||
CharServer monitora via keyspace notifications ou cleanup periódico → DECR world:{id}:reserved
|
||||
```
|
||||
|
||||
### QueryPort: dois usos distintos (separar sempre)
|
||||
|
||||
| Uso | Quem consome | Como Zeus faz | Quando |
|
||||
|---|---|---|---|
|
||||
| **(A)** Estado vivo pro cliente do jogo | Cliente Unreal logado no CharServer | HTTPS interno (Fase 1) → push Valkey (Fase 2+). Cliente sempre via WSS/CharServer | Implementado nesta frente |
|
||||
| **(B)** Visibilidade externa pública | Steam server browser, Battlemetrics, GameTracker | UDP A2S (protocolo Valve Source), porta 27015, rate-limited | Roadmap Fase 7 (Steam launch) |
|
||||
|
||||
WorldServer **não usa QueryPort UDP** pra (A) — gasto desnecessário e expõe estado interno. (B) é uma listener UDP separado, anônimo, rate-limited.
|
||||
|
||||
---
|
||||
|
||||
## Schema MySQL (Fase 1)
|
||||
|
||||
### Tabela `worlds`
|
||||
|
||||
```typescript
|
||||
@Entity({ name: 'worlds' })
|
||||
export class WorldEntity {
|
||||
@ZUuid({ primary: true })
|
||||
id!: Buffer; // UUID v4 binary(16)
|
||||
|
||||
@Column({ type: 'varchar', length: 64, unique: true })
|
||||
name!: string;
|
||||
|
||||
@ZEnum(['na', 'sa', 'eu', 'br', 'as'])
|
||||
region!: AccountRegion;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
host!: string;
|
||||
|
||||
@Column({ type: 'smallint', unsigned: true })
|
||||
port!: number;
|
||||
|
||||
@Column({ type: 'smallint', unsigned: true, default: 500 })
|
||||
capacity!: number;
|
||||
|
||||
@ZEnum(['online', 'maintenance', 'offline'], { default: 'offline' })
|
||||
state!: WorldState;
|
||||
|
||||
/** Hash Argon2id do world_secret. */
|
||||
@Column({ name: 'secret_hash', type: 'varchar', length: 255 })
|
||||
secretHash!: string;
|
||||
|
||||
@Column({ name: 'last_claim_at', type: 'datetime', nullable: true })
|
||||
lastClaimAt!: Date | null;
|
||||
|
||||
@Column({ name: 'last_claim_node', type: 'varchar', length: 64, nullable: true })
|
||||
lastClaimNode!: string | null;
|
||||
|
||||
@CreateDateColumn() createdAt!: Date;
|
||||
@UpdateDateColumn() updatedAt!: Date;
|
||||
}
|
||||
```
|
||||
|
||||
### Alteração em `characters`
|
||||
|
||||
A entity atual ([Character.entity.ts](Server/ZeusCharServer/src/database/entities/MMO/Character.entity.ts)) tem só campos básicos (id, account_id, slot, name, class_id, level, exp, posição, appearance). A Fase 1 adiciona:
|
||||
|
||||
```sql
|
||||
ALTER TABLE characters
|
||||
-- Roteamento de mundo
|
||||
ADD COLUMN world_id BINARY(16) NULL,
|
||||
ADD CONSTRAINT fk_characters_world FOREIGN KEY (world_id) REFERENCES worlds(id) ON DELETE RESTRICT,
|
||||
ADD INDEX idx_characters_account_world (account_id, world_id),
|
||||
|
||||
-- Concurrency control (writeback Fase 3)
|
||||
ADD COLUMN version INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- Stats primários estilo Ragnarok (ver ARQUITETURA_CHARACTER_MODEL.md)
|
||||
ADD COLUMN str SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
ADD COLUMN agi SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
ADD COLUMN vit SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
ADD COLUMN `int` SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
ADD COLUMN dex SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
ADD COLUMN luk SMALLINT UNSIGNED NOT NULL DEFAULT 1,
|
||||
|
||||
-- Progressão (já tem level/exp = base; adicionar job_level/job_exp)
|
||||
ADD COLUMN job_level INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
ADD COLUMN job_exp BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- HP/SP atuais e máximos (max é cache; recalcula em level/equip change)
|
||||
ADD COLUMN hp INT UNSIGNED NOT NULL DEFAULT 40,
|
||||
ADD COLUMN max_hp INT UNSIGNED NOT NULL DEFAULT 40,
|
||||
ADD COLUMN sp INT UNSIGNED NOT NULL DEFAULT 11,
|
||||
ADD COLUMN max_sp INT UNSIGNED NOT NULL DEFAULT 11,
|
||||
|
||||
-- Pontos não-gastos (allocação no level up)
|
||||
ADD COLUMN status_point INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
ADD COLUMN skill_point INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
|
||||
-- Moeda
|
||||
ADD COLUMN zeny BIGINT UNSIGNED NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
**Notas:**
|
||||
- `world_id` é nullable (sem chars em prod hoje — evita downtime futuro). Validação no service: novos chars devem ter `world_id`.
|
||||
- Estrutura flat segue rathena (`.bases/rathena/rathena-master/sql-files/main.sql:209-296`) — padrão da indústria emuladora. Sem entidade `character_stats` separada (1:1 FK seria JOIN inútil).
|
||||
- Defaults vêm da row Aprendiz no `jobs.yml` (ver doc Character Model).
|
||||
- **Stats derivados (ATK/MATK/DEF/MDEF/hit/flee/crit/aspd) NÃO entram aqui** — são sempre recalculados em runtime no WorldServer via fórmulas. Persistir é bug fest (esquece atualizar quando muda equip/buff/level).
|
||||
- `version` (optimistic locking) usado em writeback da Fase 3.
|
||||
|
||||
> **Para o detalhamento do modelo de personagem** (stats primários vs derivados, fórmulas de ATK/MATK/DEF, jobs.yml, stat allocation, leveling curve, classes Aprendiz → especialização), ver doc dedicado [`ARQUITETURA_CHARACTER_MODEL.md`](ARQUITETURA_CHARACTER_MODEL.md).
|
||||
|
||||
### Schema Valkey (Fase 2+)
|
||||
|
||||
```
|
||||
world:{id}:status HSET { pop, cap, queue_len, state, updated_at } TTL 30s (renovado a cada 5s)
|
||||
world:{id}:reserved INTEGER (sem TTL — decai por DECR)
|
||||
world:events PUB/SUB channel (transitions + player_in/out)
|
||||
queue:world:{id} ZSET { account_id → timestamp_ms } (Fase 4, Valkey Streams)
|
||||
char:active:{char_id} STRING '{world_id,node_id}' TTL 120s (renovado pelo WS)
|
||||
ticket:{token} STRING '{account_id,char_id,world_id,expires_at}' TTL 10s, GETDEL
|
||||
session:{jti} HSET { account_id, node_id, ip, expires_at } TTL = JWT TTL (Fase 5)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Roadmap em fases
|
||||
|
||||
### Fase 1 — Lista de mundos estática + char por mundo (sem Valkey)
|
||||
|
||||
**Objetivo:** ServerSelect mostra mundos reais; CharSelect filtra por mundo; cada novo char nasce vinculado.
|
||||
|
||||
**MySQL:**
|
||||
- Migration `worlds` + `characters.world_id` + `characters.version`.
|
||||
- CLI `npm run world -- create/list/set-state/rotate-secret`.
|
||||
|
||||
**CharServer (TS):**
|
||||
- `WorldRepository` + `WorldService` (list/get/setState/claim/heartbeat).
|
||||
- Opcodes novos:
|
||||
- `C_WORLD_LIST_REQUEST=2060` / `S_WORLD_LIST=2061` (region-aware — filtra por `ZEUS_REGION`).
|
||||
- Estender `C_CHAR_LIST_REQUEST` com `world_id`.
|
||||
- Estender `C_CHAR_CREATE` com `world_id` obrigatório.
|
||||
- Endpoints HTTPS (canal C):
|
||||
- `POST /interserver/worlds/{id}/claim { secret }` — Argon2id check + region check → `state=online`. **Rate-limited** (1 req/min por IP — anti crash-loop hammering).
|
||||
- `POST /interserver/worlds/{id}/heartbeat { pop, queue_len, state }` — fallback HTTP antes do Valkey (UPDATE em `worlds.last_heartbeat_at` ou tabela `worlds_live` auxiliar).
|
||||
- `S_CHAR_SELECT` valida `char.world_id == world.id` e `world.state == online`. Reject reasons novos:
|
||||
- `WorldOffline=8`, `WorldMaintenance=9`, `WorldFull=10`, `WorldRegionMismatch=11`.
|
||||
- Devolve `worldHost/Port` do mundo escolhido (não mais hardcoded).
|
||||
|
||||
**Cliente Unreal:**
|
||||
- Novo estado `EZMMOFrontEndState::CharSelect` entre ServerSelect e ingame.
|
||||
- `UUIServerSelectScreen_Base`: pede lista, popula cards, clique → `SelectedWorldId` no `UUIFrontEndFlowSubsystem` + transição CharSelect.
|
||||
- `UUICharSelectScreen_Base` (NOVO): lista filtrada por `SelectedWorldId`, criar/deletar/entrar.
|
||||
- `CharServerOpcodes.h`: novos opcodes + reasons.
|
||||
|
||||
**Métricas Prometheus (mínimas):**
|
||||
- `char_auth_total{result}`, `char_select_total{result}`, `world_list_request_total`.
|
||||
|
||||
**Files críticos:**
|
||||
- `Server/ZeusCharServer/src/database/entities/MMO/World.entity.ts` (NOVO)
|
||||
- `Server/ZeusCharServer/src/database/entities/MMO/Character.entity.ts` (add `world_id`, `version`)
|
||||
- `Server/ZeusCharServer/src/services/world.service.ts` (NOVO)
|
||||
- `Server/ZeusCharServer/src/http/routes/interserver.ts` (estender)
|
||||
- `Server/ZeusCharServer/src/services/char-{list,create,select}.service.ts`
|
||||
- `Server/ZeusCharServer/scripts/world-cli.ts` (NOVO)
|
||||
- `Server/ZeusCharServer/src/protocol/CharOpcodes.ts`
|
||||
- `Clients/ZMMO/Source/ZMMO/Game/UI/FrontEnd/UIServerSelectScreen_Base.{h,cpp}`
|
||||
- `Clients/ZMMO/Source/ZMMO/Game/UI/FrontEnd/UICharSelectScreen_Base.{h,cpp}` (NOVO)
|
||||
- `Clients/ZMMO/Source/ZMMO/Data/UI/FrontEndTypes.h`
|
||||
|
||||
### Fase 2 — Valkey + heartbeat real
|
||||
|
||||
**Objetivo:** pop/state real-time no ServerSelect; UI atualiza sem refresh.
|
||||
|
||||
**Setup:**
|
||||
- `docker-compose.yml` com `valkey/valkey:7-alpine`.
|
||||
- `ioredis` no CharServer (API 100% compatível com Valkey 7.2).
|
||||
- Cliente Valkey C++ no WorldServer (`hiredis` — C, popular; ou `cpp_redis` — header-only, mais moderno; decidir na Fase 2).
|
||||
|
||||
**WorldServer (C++):**
|
||||
- Worker thread separada (não bloqueia game loop):
|
||||
- On player_connected/disconnected: `HINCRBY` + `PUBLISH`.
|
||||
- On state_change: `HSET state` + `PUBLISH`.
|
||||
- Periodic 5s: refresh `HSET` completo + `PEXPIRE 30000`.
|
||||
- `WorldId` validado contra `worlds` no boot via HTTP claim (Fase 1).
|
||||
|
||||
**CharServer (TS):**
|
||||
- `ValkeyService` singleton (ioredis client + subscriber).
|
||||
- `WorldService.getLiveStatus(id)` lê do Valkey (fallback `state=offline` se key não existe).
|
||||
- Novo opcode `S_WORLD_STATUS_UPDATE=2062` — push pra cliente quando recebe `world:events`.
|
||||
- `S_WORLD_LIST` agora inclui `pop/queue_len/state` do Valkey no payload inicial.
|
||||
|
||||
**Cliente:**
|
||||
- ServerSelect: subscribe na ativação; listener `S_WORLD_STATUS_UPDATE` atualiza cards em tempo real.
|
||||
|
||||
**Métricas:** `world_heartbeat_age_seconds{world_id}` (alerta >30s), `world_population{world_id}`.
|
||||
|
||||
### Fase 3 — Ticket validado + writeback de char + concurrency
|
||||
|
||||
**Objetivo:** handoff seguro + persistência real do gameplay sem corrupção.
|
||||
|
||||
**CharServer:**
|
||||
- `TicketService.issue({account_id, char_id, world_id})`: `crypto.randomBytes(32)` → `SETEX ticket:{token} 10` + `INCR world:{id}:reserved`.
|
||||
- `S_CHAR_SELECT_OK` envia ticket emitido (substitui o random anterior).
|
||||
- Endpoints novos (canal C):
|
||||
- `GET /interserver/characters/{id}` — full state (pos, inv, stats, **version**).
|
||||
- `POST /interserver/characters/{id}/checkpoint { ..., version }` — optimistic UPDATE; 409 se conflict.
|
||||
- `POST /interserver/audit { type, account_id, char_id, payload }` — audit log (level up, item raro, trade).
|
||||
|
||||
**WorldServer:**
|
||||
- Handshake UDP lê ticket → `GETDEL ticket:{token}` no Valkey → valida campos.
|
||||
- Se válido: `DECR world:{id}:reserved` + `INCR pop` + `GET /interserver/characters/{id}` → spawn.
|
||||
- Worker 60s + on logout: `POST /interserver/characters/{id}/checkpoint`. Em 409 → recarrega + merge + retry.
|
||||
|
||||
**Cliente:** transparente (só passa o ticket que recebeu).
|
||||
|
||||
**Métricas:** `ticket_{issued,consumed,expired}_total`, `character_writeback_{ok,conflict}_total`.
|
||||
|
||||
### Fase 3.5 — Single sign-on enforcement
|
||||
|
||||
**Objetivo:** impedir char duplicado in-world (item dup, corrupção).
|
||||
|
||||
**CharServer:**
|
||||
- Antes de emitir ticket: `GET char:active:{char_id}`. Se existe e é mundo diferente → `POST /interserver/worlds/{active.world_id}/kick { char_id }` com timeout 3s. Se timeout → `DEL char:active:{char_id}` forçado (assumir world morreu) + log warn.
|
||||
|
||||
**WorldServer:**
|
||||
- On spawn: `SETEX char:active:{char_id} 120 '{world_id, node_id}'`.
|
||||
- Worker 60s: refresh TTL (`PEXPIRE`).
|
||||
- On despawn/logout/crash recover: `DEL char:active:{char_id}`.
|
||||
- Endpoint `POST /admin/kick { char_id, reason }`: força disconnect daquele char.
|
||||
|
||||
**Métricas:** `sso_kick_total{reason=logged_in_elsewhere|stale}`.
|
||||
|
||||
### Fase 4 — Queue com Valkey Streams (não PUB/SUB)
|
||||
|
||||
**Objetivo:** quando `pop + reserved >= cap`, enfileirar em vez de rejeitar.
|
||||
|
||||
**Por que Streams e não PUB/SUB:** PUB/SUB é fire-and-forget; CharServer reiniciando perde eventos `slot-free` → jogadores ficam eternamente na fila. Streams (XADD/XREAD/XACK) persiste mensagens, consumer pode catch-up após restart.
|
||||
|
||||
**CharServer:**
|
||||
- Em `S_CHAR_SELECT` com pop cheio: `ZADD queue:world:{id} {ts_ms} {account_id}` + responde `S_CHAR_QUEUED(position, ETA)`.
|
||||
- Worker push 5s: `S_QUEUE_UPDATE` pra cada um na fila (com posição via `ZRANK`).
|
||||
- Consumer da stream `world:slot-free:{id}`: `ZPOPMIN` da queue → emite ticket → `S_CHAR_QUEUE_READY(ticket, worldHost, worldPort)`.
|
||||
- Cleanup de zombie entries: WS disconnect → `ZREM` do account_id em todas as filas.
|
||||
|
||||
**WorldServer:**
|
||||
- Em logout/disconnect: `XADD world:slot-free:{id} * world_id={id}` (Valkey Stream).
|
||||
|
||||
**Cliente:** tela de fila (overlay), opção "Cancelar" → `C_CHAR_QUEUE_CANCEL`.
|
||||
|
||||
**Métricas:** `queue_length{world_id}`, `queue_wait_seconds_p99{world_id}`.
|
||||
|
||||
### Fase 5 — Multi-node CharServer
|
||||
|
||||
**Objetivo:** rodar N instâncias atrás de LB dentro de uma região.
|
||||
|
||||
- Sticky session por cookie no gateway (HAProxy/Nginx) — canal A WSS.
|
||||
- `session:{jti}` em Valkey HSET — revogação JWT global via `PUBLISH session:revoke`.
|
||||
- Health endpoint pro LB; readiness probe diferenciado.
|
||||
- **Connection draining**: SIGTERM handler seta `draining=true` flag, recusa novas conexões WSS mas mantém existentes até timeout (60s).
|
||||
|
||||
**Métricas:** `ws_connections_active{node_id}`, `node_draining{node_id}`.
|
||||
|
||||
### Fase 6 — Multi-region
|
||||
|
||||
**Objetivo:** roll-out NA/SA/EU/AS com DB global.
|
||||
|
||||
- MySQL Primary central + read replicas regionais (async replication).
|
||||
- Valkey cluster por região (sem cross-region sync — cada região é uma ilha de estado vivo).
|
||||
- DNS regional via GeoDNS (Route53/Cloudflare) ou subdomain estático.
|
||||
- WorldServer `CharServerEndpoint` aponta pro hostname regional.
|
||||
- Worlds filtrados por região automaticamente (`WHERE region = $ZEUS_REGION`).
|
||||
- Read-your-own-writes: flag `READ_PRIMARY=1` no service layer pra reads críticos imediatamente após write.
|
||||
- World transfer cross-region: endpoint admin `POST /admin/characters/{id}/transfer { target_world_id }`.
|
||||
- **mTLS interserver** (canal C) — cada WorldServer com cert único, CharServer valida chain. Substitui shared secret pra cross-region.
|
||||
- **JWT key rotation** via JWKS endpoint (`GET /.well-known/jwks.json`) com `kid` header.
|
||||
- **Liveness probe externa** (não só heartbeat self-reported): CharServer faz GET `/health` no WorldServer a cada 30s — detecta congelado (heartbeat fluindo + game thread morta).
|
||||
- **Disaster recovery:** GeoDNS roteia pra região mais próxima em catástrofe. Usuários re-loginam.
|
||||
|
||||
### Fase 7 — WorldServer QueryPort A2S público (sob demanda)
|
||||
|
||||
**Objetivo:** expor WorldServer pra Steam server browser, Battlemetrics, GameTracker.
|
||||
|
||||
- Listener UDP separado (porta 27015 default), independente do gameplay UDP.
|
||||
- Protocolo Valve A2S: `A2S_INFO` (0x54), `A2S_PLAYER` (0x55), `A2S_RULES` (0x56) com challenge anti-spoofing (0x41, obrigatório desde 2020).
|
||||
- Rate-limit por IP (token bucket 30 req/s).
|
||||
- Registro no Steam Master Server (`hl2master.steampowered.com:27011`).
|
||||
- **Nunca compartilha identidade com canal C** — stateless, anônimo, jamais expõe `world_secret`.
|
||||
- Flag `enable_query_port` no `server.json` (default off em dev/CI).
|
||||
- Ref: https://developer.valvesoftware.com/wiki/Server_queries
|
||||
|
||||
---
|
||||
|
||||
## Decisões consolidadas
|
||||
|
||||
1. **CharServer dono dos `characters`** (MySQL global). Coluna `world_id` (FK) + `version` (optimistic locking).
|
||||
2. **WorldServer stateless** quanto a persistência. Cache em memória + writeback ~60s.
|
||||
3. **Lista de mundos:** `worlds` no MySQL (durável). Estado vivo (pop/queue/state) em Valkey (Fase 2+).
|
||||
4. **Heartbeat WorldServer:** três triggers combinados (event + state-change + periodic 5s) escrevendo direto no Valkey (Fase 2+); HTTP POST como fallback na Fase 1.
|
||||
5. **Mundo em manutenção:** sempre acessível em ServerSelect/CharSelect; botão "Entrar" rejeita.
|
||||
6. **Char list filtrada por mundo:** escolha do mundo na ServerSelect → CharSelect com chars desse mundo. Criação inclui `world_id`.
|
||||
7. **Ticket de handoff:** opaque 32-byte token, TTL 10s, `GETDEL` no WorldServer, com **reserva de slot** (`INCR world:{id}:reserved`).
|
||||
8. **SSO enforcement:** `char:active:{char_id}` em Valkey + kick old session via canal C antes de emitir ticket novo.
|
||||
9. **Concurrency control:** coluna `version` em `characters`, optimistic UPDATE no writeback (409 → retry com merge).
|
||||
10. **Queue:** MVP sem fila (Fase 1-3 → reject `WorldFull`); **Fase 4 implementa via Valkey Streams** (não PUB/SUB) com cleanup de zombies + reserva integrada.
|
||||
11. **Valkey** (fork BSD do Redis 7.2 mantido pela Linux Foundation): a partir da Fase 2. `ioredis` no Node funciona sem mudança. Docker `valkey/valkey:7-alpine`.
|
||||
12. **`world_id`:** admin-create + WorldServer-claim com Argon2id secret (padrão AccelByte Armada). Rate-limited (anti crash-loop).
|
||||
13. **Topologia geo-distribuída:** Valkey + CharServer por região; MySQL primary global + read replicas regionais. Multi-region é Fase 6.
|
||||
14. **QueryPort:** UDP A2S público apenas pra visibilidade externa (Steam/Battlemetrics) na Fase 7. Estado pro cliente do jogo sempre via WSS/CharServer.
|
||||
15. **mTLS interserver:** Fase 6 (substitui shared secret pra cross-region).
|
||||
16. **JWT key rotation:** JWKS endpoint na Fase 6.
|
||||
17. **Métricas Prometheus:** lista mínima em cada fase (`char_auth_total`, `world_heartbeat_age_seconds`, `ticket_*_total`, `queue_length`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Verificação por fase
|
||||
|
||||
**Fase 1:**
|
||||
- `npm run world -- create --name="Aurora" --host=127.0.0.1 --port=27777 --region=br --cap=500` → imprime `world_id` + `world_secret`.
|
||||
- `npm run world -- create --name="Volcanus" --host=127.0.0.1 --port=27778 --region=na --cap=500`.
|
||||
- `npm run world -- list` mostra ambos com `state=offline`.
|
||||
- WorldServer com `world_id`+`world_secret` no `Config/server.json` sobe → `POST /interserver/worlds/{id}/claim` → `204` + log "claimed". `state` no DB vira `online`.
|
||||
- Claim com secret errado → `403` + abort.
|
||||
- PIE login → ServerSelect: 2 cards reais.
|
||||
- Clique em Aurora → CharSelect com chars de `world_id=Aurora` (filtrado).
|
||||
- Criar Personagem → INSERT com `world_id=Aurora`.
|
||||
- `npm run world -- set-state <id> maintenance` → entrar → reject `WorldMaintenance` (CharSelect ainda funciona).
|
||||
|
||||
**Fase 2:**
|
||||
- `docker-compose up -d valkey` sobe Valkey.
|
||||
- `valkey-cli HGETALL world:{id}:status` mostra pop fresh.
|
||||
- Player conecta → ServerSelect: pop incrementa em tempo real sem refresh.
|
||||
- Matar WorldServer → 30s depois ServerSelect mostra "Offline".
|
||||
|
||||
**Fase 3:**
|
||||
- WorldServer log "ticket validado, account=X char=Y".
|
||||
- Reuse de mesmo ticket → reject.
|
||||
- Editar inv in-game → 60s depois `SELECT inv FROM characters` mostra novo state.
|
||||
- Editar level via SQL direto durante gameplay → writeback do WorldServer dá 409 → recarrega + merge.
|
||||
|
||||
**Fase 3.5:**
|
||||
- Logar com char Y no PIE 1.
|
||||
- Logar mesmo char Y no PIE 2 → CharServer kicka PIE 1 antes de emitir ticket. `S_DISCONNECT` no PIE 1 com motivo "logged_in_elsewhere".
|
||||
|
||||
**Fase 4:**
|
||||
- `npm run world -- set-state <id> ...` setar `cap=1`.
|
||||
- Player A entra → ok.
|
||||
- Player B entra → fila posição 1.
|
||||
- Player A logout → Player B recebe ticket + entra.
|
||||
- Restart CharServer durante fila → Player B continua na fila (Streams persistiu).
|
||||
|
||||
**Fase 5:**
|
||||
- 2 instâncias `npm run dev` em portas diferentes + nginx LB.
|
||||
- Login → matar node ativo → reconnect cai no outro → sessão preserva (Valkey).
|
||||
- `kill -TERM` no node ativo → drena (sem cortar conexão imediatamente) → desce após timeout.
|
||||
|
||||
**Fase 6:**
|
||||
- Subir CharServer SA + CharServer NA + Valkey SA + Valkey NA + read replicas.
|
||||
- WorldServer SA claim em CharServer SA: ok.
|
||||
- WorldServer SA tentar claim em CharServer NA: 403 region mismatch.
|
||||
- Cliente conecta `sa.zeusmmo.com.br`: vê só mundos SA.
|
||||
|
||||
---
|
||||
|
||||
## Notas e riscos (após autocrítica)
|
||||
|
||||
- **Live Coding ≠ UCLASS nova**: cada nova classe C++ no cliente exige rebuild completo + restart do editor. Planejar fazer todas as classes novas de uma fase no mesmo build.
|
||||
- **Reconnect WSS no cliente:** `UZeusCharServerSubsystem` hoje não tem retry/reconnect. Gap não-trivial — implementar antes da Fase 5 (multi-node faz failover frequente).
|
||||
- **WorldServer congelado vs morto:** heartbeat continua fluindo se game thread trava mas worker thread vive. Liveness probe externa (Fase 6) detecta isso. Fase 1-5 fica como risco aceito.
|
||||
- **Argon2id no claim:** custo 50-200ms por hash. WorldServer faz 1x no boot — irrelevante. Crash-loop hammering → rate limit no endpoint (1/min/IP).
|
||||
- **Replication lag MySQL:** assumido <100ms (verdade em RDS/Aurora). VPS amador pode ser 1-5s. Medir antes da Fase 6.
|
||||
- **Limite de capacity por mundo:** ~500-1500 players com WorldServer monolítico. Acima disso precisa **world sharding interno** (zone servers) — Fase 8+ se necessário. Documentar como limite atual.
|
||||
- **Chat global, party, friends:** fora desta frente. Entram como **serviço paralelo no CharServer** (canal A, opcodes 3000+) depois da Fase 3.
|
||||
- **Anti-cheat client-side (EAC/BattlEye/VAC):** decisão comercial pós-launch. Fora de escopo.
|
||||
- **Area of Interest no WorldServer:** indispensável pra >100 players concorrentes. Não bloqueia Fase 1-5 (dev tem 1-10 players). Documentar como pré-requisito de teste de carga.
|
||||
- **CI/CD multi-region:** Fase 6 pressupõe Kubernetes ou equivalente. Decisão de ops, fora desta arquitetura.
|
||||
|
||||
---
|
||||
|
||||
## O que esta frente entrega vs roadmap completo
|
||||
|
||||
**Esta frente (Fases 1-3.5):** ServerSelect funcional + char por mundo + handoff seguro + SSO + writeback sem corrupção. Suficiente pra dev real com 1-50 players concorrentes em 1 região, 1-5 mundos.
|
||||
|
||||
**Roadmap longo (Fases 4-7):** queue, multi-node, multi-region, A2S público. Cada uma incremental — não quebra as anteriores.
|
||||
|
||||
**Fora do roadmap (frentes paralelas):** chat global/party/friends, anti-cheat client-side, world sharding interno (>1000 players), CI/CD K8s, observability completa (tracing distribuído), disaster recovery automatizado.
|
||||
Binary file not shown.
BIN
Content/ZMMO/UI/FrontEnd/Lobby/WBP_CharCard.uasset
Normal file
BIN
Content/ZMMO/UI/FrontEnd/Lobby/WBP_CharCard.uasset
Normal file
Binary file not shown.
BIN
Content/ZMMO/UI/FrontEnd/Lobby/WBP_CharacterCreate.uasset
Normal file
BIN
Content/ZMMO/UI/FrontEnd/Lobby/WBP_CharacterCreate.uasset
Normal file
Binary file not shown.
BIN
Content/ZMMO/UI/FrontEnd/Lobby/WBP_UserLobby.uasset
Normal file
BIN
Content/ZMMO/UI/FrontEnd/Lobby/WBP_UserLobby.uasset
Normal file
Binary file not shown.
BIN
Content/ZMMO/UI/FrontEnd/ServerSelect/WBP_ServerCard.uasset
Normal file
BIN
Content/ZMMO/UI/FrontEnd/ServerSelect/WBP_ServerCard.uasset
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Content/ZMMO/UI/FrontEnd/WBP_ServerSelect.uasset
Normal file
BIN
Content/ZMMO/UI/FrontEnd/WBP_ServerSelect.uasset
Normal file
Binary file not shown.
Binary file not shown.
@@ -28,6 +28,15 @@ struct ZMMO_API FUIStyle
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Style")
|
||||
FUIStyleText Text;
|
||||
|
||||
/**
|
||||
* Categorias tipográficas nomeadas (data-driven). O autor define as
|
||||
* chaves aqui no DT_UI_Styles ("Title", "Section", "Body", "Label",
|
||||
* "Dim", ou customizadas); o UUICommonText_Base escolhe pelo nome
|
||||
* (dropdown populado a partir destas chaves — sem enum fixo em C++).
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Style")
|
||||
TMap<FName, FUITextStyle> TextRoles;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Style")
|
||||
FUIStyleButton Button;
|
||||
|
||||
@@ -46,6 +55,12 @@ struct ZMMO_API FUIStyle
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Style")
|
||||
FUIStyleSpinner Spinner;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Style")
|
||||
FUIStyleCheckBox CheckBox;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI Style")
|
||||
FUIStyleInput Input;
|
||||
|
||||
/**
|
||||
* Fundo de tela cheia do front-end (ex.: Boot/Login). Brush data-driven:
|
||||
* a tela aplica no seu Border_Bg via RefreshUIStyle — nunca hard-ref no
|
||||
|
||||
@@ -21,14 +21,12 @@ enum class EUITheme : uint8
|
||||
RPG UMETA(DisplayName = "RPG")
|
||||
};
|
||||
|
||||
/** Variante visual do botão (mapeia 1:1 com os campos de FUIStyleButton). */
|
||||
/** Posição do rótulo no UUIInput_Base. */
|
||||
UENUM(BlueprintType)
|
||||
enum class EUIButtonVariant : uint8
|
||||
enum class EUIInputLabelLayout : uint8
|
||||
{
|
||||
Primary UMETA(DisplayName = "Primary"),
|
||||
Secondary UMETA(DisplayName = "Secondary"),
|
||||
Danger UMETA(DisplayName = "Danger"),
|
||||
Ghost UMETA(DisplayName = "Ghost")
|
||||
Stacked UMETA(DisplayName = "Stacked (label acima)"),
|
||||
Inline UMETA(DisplayName = "Inline (label à esquerda)")
|
||||
};
|
||||
|
||||
/** Forma/silhueta de um botão — usada pelo widget para escolher dimensões. */
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
/**
|
||||
* Opcodes do CharServer (WebSocket) consumidos pelo cliente. Espelha
|
||||
* `Server/ZeusCharServer/src/protocol/CharOpcodes.ts` (faixa 2000-2099).
|
||||
* Mantido mínimo: só os opcodes que o front-end usa hoje (auth da Login).
|
||||
* Ampliar conforme as telas (lista/criação/seleção de personagem) chegarem.
|
||||
*
|
||||
* Wire: string = uint16 LE (len) + UTF-8; inteiros LE. O cabeçalho de 32B
|
||||
* é responsabilidade do UZeusCharServerSubsystem (Send/OnRawMessage operam
|
||||
@@ -18,13 +16,40 @@ namespace ZMMOCharOp
|
||||
constexpr int32 C_CHAR_AUTH_REQUEST = 2000;
|
||||
constexpr int32 S_CHAR_AUTH_OK = 2001; // string(accountId)+string(user)+uint64(expiresMs)
|
||||
constexpr int32 S_CHAR_AUTH_REJECT = 2002; // uint16(reason)
|
||||
|
||||
// Listagem/criação/seleção de personagem
|
||||
constexpr int32 C_CHAR_LIST_REQUEST = 2010; // uint8 hasWorldFilter [+ uint8[16] worldId]
|
||||
constexpr int32 S_CHAR_LIST = 2011;
|
||||
|
||||
constexpr int32 C_CHAR_CREATE = 2020; // uint8[16] worldId + slot + ...
|
||||
constexpr int32 S_CHAR_CREATE_OK = 2021;
|
||||
constexpr int32 S_CHAR_CREATE_REJECT= 2022;
|
||||
|
||||
// Delete agendado (rathena-style two-step)
|
||||
constexpr int32 C_CHAR_DELETE_REQUEST = 2030;
|
||||
constexpr int32 S_CHAR_DELETE_ACK = 2031;
|
||||
constexpr int32 C_CHAR_DELETE_ACCEPT = 2032;
|
||||
constexpr int32 S_CHAR_DELETE_ACCEPT_ACK = 2033;
|
||||
constexpr int32 C_CHAR_DELETE_CANCEL = 2034;
|
||||
constexpr int32 S_CHAR_DELETE_CANCEL_ACK = 2035;
|
||||
|
||||
constexpr int32 C_CHAR_SELECT = 2040;
|
||||
constexpr int32 S_CHAR_SELECT_OK = 2041;
|
||||
constexpr int32 S_CHAR_SELECT_REJECT= 2042;
|
||||
|
||||
// Listagem de mundos (Fase 1 do ARQUITETURA_SERVER_SELECT)
|
||||
constexpr int32 C_WORLD_LIST_REQUEST = 2060; // payload vazio
|
||||
constexpr int32 S_WORLD_LIST = 2061; // uint16 count + entries
|
||||
/**
|
||||
* Push do CharServer com update de UM mundo (state/pop/queueLen).
|
||||
* Wire: string worldId + uint16 pop + uint16 queueLen + uint8 state
|
||||
*/
|
||||
constexpr int32 S_WORLD_STATUS_UPDATE = 2062;
|
||||
}
|
||||
|
||||
/**
|
||||
* Método de autenticação enviado como uint8 prefix no payload do
|
||||
* C_CHAR_AUTH_REQUEST. TOKEN mantém o fluxo Stub/JWT (handoff de outro
|
||||
* serviço); PASSWORD chama AccountService.login direto no CharServer.
|
||||
* Espelha `CharAuthMethod` em CharOpcodes.ts.
|
||||
* C_CHAR_AUTH_REQUEST.
|
||||
*/
|
||||
namespace ZMMOCharAuthMethod
|
||||
{
|
||||
@@ -32,7 +57,7 @@ namespace ZMMOCharAuthMethod
|
||||
constexpr uint8 PASSWORD = 1;
|
||||
}
|
||||
|
||||
/** Espelha `CharRejectReason` em CharOpcodes.ts (subset usado no front-end). */
|
||||
/** Espelha `CharRejectReason` em CharOpcodes.ts. */
|
||||
namespace ZMMOCharRejectReason
|
||||
{
|
||||
constexpr uint16 Unknown = 0;
|
||||
@@ -43,4 +68,23 @@ namespace ZMMOCharRejectReason
|
||||
constexpr uint16 AccountLocked = 5;
|
||||
constexpr uint16 AccountSuspended = 6;
|
||||
constexpr uint16 CredentialsAuthDisabled = 7;
|
||||
constexpr uint16 WorldOffline = 8;
|
||||
constexpr uint16 WorldMaintenance = 9;
|
||||
constexpr uint16 WorldFull = 10;
|
||||
constexpr uint16 WorldRegionMismatch = 11;
|
||||
constexpr uint16 WorldNotFound = 12;
|
||||
constexpr uint16 NameInUse = 20;
|
||||
constexpr uint16 InvalidName = 21;
|
||||
constexpr uint16 SlotOccupied = 22;
|
||||
constexpr uint16 CharNotFound = 23;
|
||||
constexpr uint16 DeleteScheduled = 24;
|
||||
constexpr uint16 DeleteNotScheduled = 25;
|
||||
}
|
||||
|
||||
/** Estado do mundo no wire (uint8). Espelha `WireWorldState` em CharOpcodes.ts. */
|
||||
namespace ZMMOWireWorldState
|
||||
{
|
||||
constexpr uint8 Offline = 0;
|
||||
constexpr uint8 Online = 1;
|
||||
constexpr uint8 Maintenance = 2;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace
|
||||
{
|
||||
// Runtime usa o tema ativo; design-time carrega a row "Default" de
|
||||
// DT_UI_Styles para o Designer ver o fundo (mesmo padrão de UUIPanel_Base).
|
||||
const FUIStyle& ResolveBootStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
FUIStyle ResolveBootStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
{
|
||||
if (Widget)
|
||||
{
|
||||
@@ -28,22 +28,21 @@ namespace
|
||||
}
|
||||
}
|
||||
}
|
||||
static FUIStyle DesignStyle;
|
||||
static bool bLoaded = false;
|
||||
if (!bLoaded)
|
||||
// Design-time relê o DT a cada chamada: editar o DT_UI_Styles e
|
||||
// RECOMPILAR o WBP já reflete (sem reiniciar o editor).
|
||||
FUIStyle DesignStyle;
|
||||
bool bHas = false;
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UIBootDesign"), false))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UIBootDesign"), false))
|
||||
{
|
||||
DesignStyle = Row->Style;
|
||||
bLoaded = true;
|
||||
}
|
||||
DesignStyle = Row->Style;
|
||||
bHas = true;
|
||||
}
|
||||
}
|
||||
return bLoaded ? DesignStyle : Fallback;
|
||||
return bHas ? DesignStyle : Fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +97,7 @@ void UUIBootScreen_Base::RefreshUIStyle_Implementation()
|
||||
Super::RefreshUIStyle_Implementation();
|
||||
|
||||
const FUIStyle Fallback;
|
||||
const FUIStyle& AS = ResolveBootStyle(this, Fallback);
|
||||
const FUIStyle AS = ResolveBootStyle(this, Fallback);
|
||||
|
||||
if (Border_Bg)
|
||||
{
|
||||
|
||||
151
Source/ZMMO/Game/UI/FrontEnd/UICharCard_Base.cpp
Normal file
151
Source/ZMMO/Game/UI/FrontEnd/UICharCard_Base.cpp
Normal file
@@ -0,0 +1,151 @@
|
||||
#include "UICharCard_Base.h"
|
||||
|
||||
#include "ZMMO.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "Engine/World.h"
|
||||
#include "TimerManager.h"
|
||||
#include "UI/Widgets/UIButton_Base.h"
|
||||
|
||||
void UUICharCard_Base::NativeConstruct()
|
||||
{
|
||||
Super::NativeConstruct();
|
||||
if (!bBound)
|
||||
{
|
||||
if (Btn_Select) Btn_Select->OnClicked().AddUObject(this, &UUICharCard_Base::HandleSelectClicked);
|
||||
if (Btn_Delete) Btn_Delete->OnClicked().AddUObject(this, &UUICharCard_Base::HandleDeleteClicked);
|
||||
if (Btn_AcceptDelete) Btn_AcceptDelete->OnClicked().AddUObject(this, &UUICharCard_Base::HandleAcceptDeleteClicked);
|
||||
if (Btn_CancelDelete) Btn_CancelDelete->OnClicked().AddUObject(this, &UUICharCard_Base::HandleCancelDeleteClicked);
|
||||
bBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
void UUICharCard_Base::NativeDestruct()
|
||||
{
|
||||
StopCountdownTimer();
|
||||
if (bBound)
|
||||
{
|
||||
if (Btn_Select) Btn_Select->OnClicked().RemoveAll(this);
|
||||
if (Btn_Delete) Btn_Delete->OnClicked().RemoveAll(this);
|
||||
if (Btn_AcceptDelete) Btn_AcceptDelete->OnClicked().RemoveAll(this);
|
||||
if (Btn_CancelDelete) Btn_CancelDelete->OnClicked().RemoveAll(this);
|
||||
bBound = false;
|
||||
}
|
||||
Super::NativeDestruct();
|
||||
}
|
||||
|
||||
void UUICharCard_Base::SetFromSummary(const FZMMOCharSummary& InSummary)
|
||||
{
|
||||
Summary = InSummary;
|
||||
const bool bDeleteScheduled = Summary.DeleteScheduledAtMs > 0;
|
||||
|
||||
if (Text_Name)
|
||||
{
|
||||
Text_Name->SetText(FText::FromString(Summary.Name));
|
||||
}
|
||||
if (Text_Level)
|
||||
{
|
||||
Text_Level->SetText(FText::FromString(FString::Printf(TEXT("Lv %d / %d"), Summary.BaseLevel, Summary.JobLevel)));
|
||||
}
|
||||
if (Text_Class)
|
||||
{
|
||||
Text_Class->SetText(FText::FromString(FString::Printf(TEXT("Classe %d"), Summary.ClassId)));
|
||||
}
|
||||
|
||||
const ESlateVisibility ActionVis = bDeleteScheduled ? ESlateVisibility::Collapsed : ESlateVisibility::Visible;
|
||||
const ESlateVisibility ConfirmVis = bDeleteScheduled ? ESlateVisibility::Visible : ESlateVisibility::Collapsed;
|
||||
if (Btn_Select) Btn_Select->SetVisibility(ActionVis);
|
||||
if (Btn_Delete) Btn_Delete->SetVisibility(ActionVis);
|
||||
if (Btn_AcceptDelete) Btn_AcceptDelete->SetVisibility(ConfirmVis);
|
||||
if (Btn_CancelDelete) Btn_CancelDelete->SetVisibility(ConfirmVis);
|
||||
|
||||
if (Text_DeleteCountdown)
|
||||
{
|
||||
Text_DeleteCountdown->SetVisibility(bDeleteScheduled
|
||||
? ESlateVisibility::HitTestInvisible
|
||||
: ESlateVisibility::Collapsed);
|
||||
}
|
||||
|
||||
if (bDeleteScheduled)
|
||||
{
|
||||
TickCountdown();
|
||||
StartCountdownTimer();
|
||||
}
|
||||
else
|
||||
{
|
||||
StopCountdownTimer();
|
||||
}
|
||||
|
||||
OnSummaryApplied(bDeleteScheduled);
|
||||
}
|
||||
|
||||
void UUICharCard_Base::TickCountdown()
|
||||
{
|
||||
if (!Text_DeleteCountdown || Summary.DeleteScheduledAtMs <= 0) return;
|
||||
|
||||
// FDateTime::UtcNow().ToUnixTimestamp() retorna segundos UNIX UTC.
|
||||
// Multiplicamos por 1000 pra equiparar com Summary.DeleteScheduledAtMs (ms).
|
||||
const int64 NowMs = FDateTime::UtcNow().ToUnixTimestamp() * 1000LL;
|
||||
const int64 RemainingMs = Summary.DeleteScheduledAtMs - NowMs;
|
||||
|
||||
if (RemainingMs <= 0)
|
||||
{
|
||||
Text_DeleteCountdown->SetText(FText::FromString(TEXT("Pronto para deletar")));
|
||||
// Pode habilitar visualmente o Btn_AcceptDelete aqui se quiser feedback
|
||||
// (ja esta enabled por default; futuramente pode mudar variant cor).
|
||||
StopCountdownTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const int64 TotalSec = RemainingMs / 1000;
|
||||
if (TotalSec >= 60)
|
||||
{
|
||||
const int64 Mins = TotalSec / 60;
|
||||
const int64 Secs = TotalSec % 60;
|
||||
Text_DeleteCountdown->SetText(FText::FromString(FString::Printf(
|
||||
TEXT("Delete em %lldm %02llds"), static_cast<long long>(Mins), static_cast<long long>(Secs))));
|
||||
}
|
||||
else
|
||||
{
|
||||
Text_DeleteCountdown->SetText(FText::FromString(FString::Printf(
|
||||
TEXT("Delete em %llds"), static_cast<long long>(TotalSec))));
|
||||
}
|
||||
}
|
||||
|
||||
void UUICharCard_Base::StartCountdownTimer()
|
||||
{
|
||||
UWorld* W = GetWorld();
|
||||
if (!W) return;
|
||||
if (CountdownTimerHandle.IsValid()) return;
|
||||
W->GetTimerManager().SetTimer(CountdownTimerHandle, this,
|
||||
&UUICharCard_Base::TickCountdown, 1.0f, true /* loop */, 1.0f /* first delay */);
|
||||
}
|
||||
|
||||
void UUICharCard_Base::StopCountdownTimer()
|
||||
{
|
||||
if (!CountdownTimerHandle.IsValid()) return;
|
||||
if (UWorld* W = GetWorld())
|
||||
{
|
||||
W->GetTimerManager().ClearTimer(CountdownTimerHandle);
|
||||
}
|
||||
CountdownTimerHandle.Invalidate();
|
||||
}
|
||||
|
||||
void UUICharCard_Base::HandleSelectClicked()
|
||||
{
|
||||
OnCardSelected.Broadcast(Summary.CharId);
|
||||
}
|
||||
|
||||
void UUICharCard_Base::HandleDeleteClicked()
|
||||
{
|
||||
OnCardDeleteRequest.Broadcast(Summary.CharId);
|
||||
}
|
||||
|
||||
void UUICharCard_Base::HandleAcceptDeleteClicked()
|
||||
{
|
||||
OnCardDeleteAccept.Broadcast(Summary.CharId);
|
||||
}
|
||||
|
||||
void UUICharCard_Base::HandleCancelDeleteClicked()
|
||||
{
|
||||
OnCardDeleteCancel.Broadcast(Summary.CharId);
|
||||
}
|
||||
91
Source/ZMMO/Game/UI/FrontEnd/UICharCard_Base.h
Normal file
91
Source/ZMMO/Game/UI/FrontEnd/UICharCard_Base.h
Normal file
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "ZMMOCharSummary.h"
|
||||
#include "UICharCard_Base.generated.h"
|
||||
|
||||
class UCommonTextBlock;
|
||||
class UBorder;
|
||||
class UUIButton_Base;
|
||||
class UWidget;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FZMMOOnCharCardSelected, FString, CharId);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FZMMOOnCharCardDeleteRequest, FString, CharId);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FZMMOOnCharCardDeleteAccept, FString, CharId);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FZMMOOnCharCardDeleteCancel, FString, CharId);
|
||||
|
||||
/**
|
||||
* Card de personagem (instanciado dinamicamente pelo Lobby/CharSelect).
|
||||
*
|
||||
* Quando `Summary.DeleteScheduledAtMs > 0`, o card alterna os botoes:
|
||||
* - Btn_Select → escondido
|
||||
* - Btn_Delete → escondido
|
||||
* - Btn_AcceptDelete → visivel (efetiva o delete depois da janela)
|
||||
* - Btn_CancelDelete → visivel (cancela o agendamento)
|
||||
*
|
||||
* Visibilidade so e alterada se os widgets opcionais estiverem bindados —
|
||||
* o WBP pode optar por mostrar/esconder via BP override.
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable)
|
||||
class ZMMO_API UUICharCard_Base : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|CharCard")
|
||||
void SetFromSummary(const FZMMOCharSummary& InSummary);
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|CharCard")
|
||||
FZMMOCharSummary Summary;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|CharCard")
|
||||
FZMMOOnCharCardSelected OnCardSelected;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|CharCard")
|
||||
FZMMOOnCharCardDeleteRequest OnCardDeleteRequest;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|CharCard")
|
||||
FZMMOOnCharCardDeleteAccept OnCardDeleteAccept;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|CharCard")
|
||||
FZMMOOnCharCardDeleteCancel OnCardDeleteCancel;
|
||||
|
||||
/**
|
||||
* Hook BP — chamado apos SetFromSummary aplicar valores. Util pra
|
||||
* ajustar visual quando delete esta agendado (badges, cores, etc).
|
||||
*/
|
||||
UFUNCTION(BlueprintImplementableEvent, Category = "Zeus|CharCard")
|
||||
void OnSummaryApplied(bool bIsDeleteScheduled);
|
||||
|
||||
protected:
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
void HandleSelectClicked();
|
||||
void HandleDeleteClicked();
|
||||
void HandleAcceptDeleteClicked();
|
||||
void HandleCancelDeleteClicked();
|
||||
|
||||
/** Re-renderiza o texto do countdown (chamado pelo timer 1s). */
|
||||
UFUNCTION()
|
||||
void TickCountdown();
|
||||
|
||||
void StartCountdownTimer();
|
||||
void StopCountdownTimer();
|
||||
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UBorder> Card_Bg;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Name;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Level;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Class;
|
||||
UPROPERTY(meta = (BindWidget)) TObjectPtr<UUIButton_Base> Btn_Select;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUIButton_Base> Btn_Delete;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUIButton_Base> Btn_AcceptDelete;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUIButton_Base> Btn_CancelDelete;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_DeleteCountdown;
|
||||
|
||||
private:
|
||||
bool bBound = false;
|
||||
|
||||
FTimerHandle CountdownTimerHandle;
|
||||
};
|
||||
133
Source/ZMMO/Game/UI/FrontEnd/UICharacterCreatePage_Base.cpp
Normal file
133
Source/ZMMO/Game/UI/FrontEnd/UICharacterCreatePage_Base.cpp
Normal file
@@ -0,0 +1,133 @@
|
||||
#include "UICharacterCreatePage_Base.h"
|
||||
|
||||
#include "ZMMO.h"
|
||||
#include "CharServerOpcodes.h"
|
||||
#include "UIFrontEndFlowSubsystem.h"
|
||||
#include "ZeusCharServerSubsystem.h"
|
||||
#include "Components/EditableTextBox.h"
|
||||
#include "Components/ComboBoxString.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "UI/Widgets/UIButton_Base.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
bool WriteUuid16(TArray<uint8>& Buf, const FString& Uuid)
|
||||
{
|
||||
FString Hex = Uuid.Replace(TEXT("-"), TEXT(""));
|
||||
if (Hex.Len() != 32) return false;
|
||||
for (int32 i = 0; i < 16; ++i)
|
||||
{
|
||||
TCHAR hi = Hex[i * 2];
|
||||
TCHAR lo = Hex[i * 2 + 1];
|
||||
auto HexVal = [](TCHAR c) -> int32 {
|
||||
if (c >= TEXT('0') && c <= TEXT('9')) return c - TEXT('0');
|
||||
if (c >= TEXT('a') && c <= TEXT('f')) return c - TEXT('a') + 10;
|
||||
if (c >= TEXT('A') && c <= TEXT('F')) return c - TEXT('A') + 10;
|
||||
return -1;
|
||||
};
|
||||
int32 H = HexVal(hi);
|
||||
int32 L = HexVal(lo);
|
||||
if (H < 0 || L < 0) return false;
|
||||
Buf.Add(static_cast<uint8>((H << 4) | L));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void WriteUInt8(TArray<uint8>& Buf, uint8 v) { Buf.Add(v); }
|
||||
void WriteUInt16(TArray<uint8>& Buf, uint16 v)
|
||||
{
|
||||
Buf.Add(static_cast<uint8>(v & 0xFF));
|
||||
Buf.Add(static_cast<uint8>((v >> 8) & 0xFF));
|
||||
}
|
||||
void WriteUInt32(TArray<uint8>& Buf, uint32 v)
|
||||
{
|
||||
for (int32 i = 0; i < 4; ++i) Buf.Add(static_cast<uint8>((v >> (8 * i)) & 0xFF));
|
||||
}
|
||||
void WriteStringUtf8(TArray<uint8>& Buf, const FString& S)
|
||||
{
|
||||
FTCHARToUTF8 Conv(*S);
|
||||
const int32 Len = Conv.Length();
|
||||
WriteUInt16(Buf, static_cast<uint16>(Len));
|
||||
Buf.Append(reinterpret_cast<const uint8*>(Conv.Get()), Len);
|
||||
}
|
||||
}
|
||||
|
||||
void UUICharacterCreatePage_Base::NativeConstruct()
|
||||
{
|
||||
Super::NativeConstruct();
|
||||
if (!bBound)
|
||||
{
|
||||
if (Btn_Confirm) Btn_Confirm->OnClicked().AddUObject(this, &UUICharacterCreatePage_Base::HandleSubmitClicked);
|
||||
if (Btn_Cancel) Btn_Cancel->OnClicked().AddUObject(this, &UUICharacterCreatePage_Base::HandleCancelClicked);
|
||||
bBound = true;
|
||||
}
|
||||
if (Text_Error) Text_Error->SetText(FText::GetEmpty());
|
||||
}
|
||||
|
||||
void UUICharacterCreatePage_Base::NativeDestruct()
|
||||
{
|
||||
if (bBound)
|
||||
{
|
||||
if (Btn_Confirm) Btn_Confirm->OnClicked().RemoveAll(this);
|
||||
if (Btn_Cancel) Btn_Cancel->OnClicked().RemoveAll(this);
|
||||
bBound = false;
|
||||
}
|
||||
Super::NativeDestruct();
|
||||
}
|
||||
|
||||
void UUICharacterCreatePage_Base::SubmitCreate()
|
||||
{
|
||||
const FString Name = Input_Name ? Input_Name->GetText().ToString() : FString();
|
||||
if (Name.IsEmpty())
|
||||
{
|
||||
if (Text_Error) Text_Error->SetText(FText::FromString(TEXT("Informe um nome.")));
|
||||
return;
|
||||
}
|
||||
|
||||
UGameInstance* GI = GetGameInstance();
|
||||
if (!GI) return;
|
||||
UUIFrontEndFlowSubsystem* Flow = GI->GetSubsystem<UUIFrontEndFlowSubsystem>();
|
||||
UZeusCharServerSubsystem* Char = GI->GetSubsystem<UZeusCharServerSubsystem>();
|
||||
if (!Flow || !Char) return;
|
||||
|
||||
const FString WorldId = Flow->GetSelectedWorldId();
|
||||
if (WorldId.IsEmpty())
|
||||
{
|
||||
if (Text_Error) Text_Error->SetText(FText::FromString(TEXT("Mundo nao selecionado.")));
|
||||
return;
|
||||
}
|
||||
|
||||
int32 ClassId = 0;
|
||||
if (Combo_Class)
|
||||
{
|
||||
const FString Sel = Combo_Class->GetSelectedOption();
|
||||
LexFromString(ClassId, *Sel);
|
||||
}
|
||||
|
||||
// Slot: simples, sempre 0 por enquanto (servidor valida unicidade na conta).
|
||||
const uint8 SlotIdx = 0;
|
||||
|
||||
TArray<uint8> Payload;
|
||||
if (!WriteUuid16(Payload, WorldId))
|
||||
{
|
||||
if (Text_Error) Text_Error->SetText(FText::FromString(TEXT("WorldId invalido.")));
|
||||
return;
|
||||
}
|
||||
WriteUInt8(Payload, SlotIdx);
|
||||
WriteStringUtf8(Payload, Name);
|
||||
WriteUInt16(Payload, static_cast<uint16>(ClassId));
|
||||
WriteUInt32(Payload, 0); // hair
|
||||
WriteUInt32(Payload, 0); // hairColor
|
||||
WriteUInt32(Payload, 0); // skinColor
|
||||
WriteUInt8(Payload, 0); // bodyType
|
||||
|
||||
Char->SendCharRequest(ZMMOCharOp::C_CHAR_CREATE, Payload);
|
||||
if (Text_Error) Text_Error->SetText(FText::GetEmpty());
|
||||
OnRequestSent.Broadcast();
|
||||
}
|
||||
|
||||
void UUICharacterCreatePage_Base::CancelCreate()
|
||||
{
|
||||
OnCancelled.Broadcast();
|
||||
}
|
||||
58
Source/ZMMO/Game/UI/FrontEnd/UICharacterCreatePage_Base.h
Normal file
58
Source/ZMMO/Game/UI/FrontEnd/UICharacterCreatePage_Base.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "UICharacterCreatePage_Base.generated.h"
|
||||
|
||||
class UEditableTextBox;
|
||||
class UComboBoxString;
|
||||
class UCommonTextBlock;
|
||||
class UUIButton_Base;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FZMMOOnCharCreateRequestSent);
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FZMMOOnCharCreateCancelled);
|
||||
|
||||
/**
|
||||
* Page de criacao de personagem dentro do Lobby. Form minimo: nome + classId
|
||||
* (combo) + appearance default. Quando confirma, monta o payload do
|
||||
* C_CHAR_CREATE e envia via UZeusCharServerSubsystem. O Lobby host escuta
|
||||
* S_CHAR_CREATE_OK/REJECT.
|
||||
*
|
||||
* Wire C_CHAR_CREATE (espelha char-create.service.ts):
|
||||
* uint8[16] worldId + uint8 slot + string name + uint16 classId
|
||||
* + uint32 hair + uint32 hairColor + uint32 skinColor + uint8 bodyType
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable)
|
||||
class ZMMO_API UUICharacterCreatePage_Base : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|CharCreate")
|
||||
void SubmitCreate();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|CharCreate")
|
||||
void CancelCreate();
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|CharCreate")
|
||||
FZMMOOnCharCreateRequestSent OnRequestSent;
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|CharCreate")
|
||||
FZMMOOnCharCreateCancelled OnCancelled;
|
||||
|
||||
protected:
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
void HandleSubmitClicked() { SubmitCreate(); }
|
||||
void HandleCancelClicked() { CancelCreate(); }
|
||||
|
||||
UPROPERTY(meta = (BindWidget)) TObjectPtr<UEditableTextBox> Input_Name;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UComboBoxString> Combo_Class;
|
||||
UPROPERTY(meta = (BindWidget)) TObjectPtr<UUIButton_Base> Btn_Confirm;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUIButton_Base> Btn_Cancel;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Error;
|
||||
|
||||
private:
|
||||
bool bBound = false;
|
||||
};
|
||||
@@ -154,6 +154,10 @@ bool UUIFrontEndFlowSubsystem::RequestBack()
|
||||
{
|
||||
switch (CurrentState)
|
||||
{
|
||||
case EZMMOFrontEndState::Login:
|
||||
// Volta à Boot (CharServer segue conectado; só re-mostra a tela).
|
||||
SetState(EZMMOFrontEndState::Boot);
|
||||
return true;
|
||||
case EZMMOFrontEndState::ServerSelect:
|
||||
SetState(EZMMOFrontEndState::Login);
|
||||
return true;
|
||||
@@ -330,6 +334,29 @@ void UUIFrontEndFlowSubsystem::RequestEnterLogin()
|
||||
}
|
||||
}
|
||||
|
||||
void UUIFrontEndFlowSubsystem::RequestEnterServerSelect()
|
||||
{
|
||||
if (CurrentState == EZMMOFrontEndState::Login)
|
||||
{
|
||||
SetState(EZMMOFrontEndState::ServerSelect);
|
||||
}
|
||||
}
|
||||
|
||||
void UUIFrontEndFlowSubsystem::SetSelectedWorldId(const FString& WorldId)
|
||||
{
|
||||
SelectedWorldId = WorldId;
|
||||
UE_LOG(LogZMMO, Log, TEXT("FrontEndFlow: mundo selecionado = %s"), *SelectedWorldId);
|
||||
}
|
||||
|
||||
void UUIFrontEndFlowSubsystem::ClearSelectedWorld()
|
||||
{
|
||||
if (!SelectedWorldId.IsEmpty())
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("FrontEndFlow: limpando mundo selecionado (%s)"), *SelectedWorldId);
|
||||
SelectedWorldId.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
void UUIFrontEndFlowSubsystem::HandleServerTravelRequested(const FString& MapName, const FString& MapPath)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("FrontEndFlow: travel pedido pelo servidor → %s (%s)."), *MapName, *MapPath);
|
||||
|
||||
@@ -62,6 +62,24 @@ public:
|
||||
UFUNCTION(BlueprintCallable, Category = "FrontEnd")
|
||||
void RequestEnterLogin();
|
||||
|
||||
/** Chamado pela tela Login após autenticação OK no CharServer. */
|
||||
UFUNCTION(BlueprintCallable, Category = "FrontEnd")
|
||||
void RequestEnterServerSelect();
|
||||
|
||||
/**
|
||||
* Memoriza o mundo escolhido na ServerSelect (UUID v4 string). Lido pelo
|
||||
* CharSelect/CharCreate na hora de filtrar/criar personagens. Limpo no
|
||||
* logout/back para ServerSelect.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "FrontEnd|Worlds")
|
||||
void SetSelectedWorldId(const FString& WorldId);
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "FrontEnd|Worlds")
|
||||
FString GetSelectedWorldId() const { return SelectedWorldId; }
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "FrontEnd|Worlds")
|
||||
void ClearSelectedWorld();
|
||||
|
||||
UPROPERTY(BlueprintAssignable, Category = "FrontEnd")
|
||||
FOnZMMOFrontEndStateChanged OnStateChanged;
|
||||
|
||||
@@ -106,6 +124,9 @@ private:
|
||||
UPROPERTY(Transient)
|
||||
EZMMOFrontEndState CurrentState = EZMMOFrontEndState::None;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
FString SelectedWorldId;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TObjectPtr<UUIFrontEndScreenSet> ScreenSet;
|
||||
|
||||
|
||||
92
Source/ZMMO/Game/UI/FrontEnd/UIServerCard_Base.cpp
Normal file
92
Source/ZMMO/Game/UI/FrontEnd/UIServerCard_Base.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include "UIServerCard_Base.h"
|
||||
|
||||
#include "ZMMO.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "Components/Border.h"
|
||||
#include "Components/ProgressBar.h"
|
||||
#include "UI/Widgets/UIButton_Base.h"
|
||||
|
||||
void UUIServerCard_Base::NativePreConstruct()
|
||||
{
|
||||
Super::NativePreConstruct();
|
||||
// Reaplica tema do botao antes de qualquer SetIsEnabled — garante que o
|
||||
// background da variante "Primary" pinta tanto em Normal quanto Disabled.
|
||||
if (Btn_Enter)
|
||||
{
|
||||
Btn_Enter->RefreshUIStyle();
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerCard_Base::NativeConstruct()
|
||||
{
|
||||
Super::NativeConstruct();
|
||||
if (Btn_Enter && !bBound)
|
||||
{
|
||||
Btn_Enter->OnClicked().AddUObject(this, &UUIServerCard_Base::HandleEnterClicked);
|
||||
bBound = true;
|
||||
}
|
||||
if (Btn_Enter)
|
||||
{
|
||||
Btn_Enter->RefreshUIStyle();
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerCard_Base::NativeDestruct()
|
||||
{
|
||||
if (Btn_Enter && bBound)
|
||||
{
|
||||
Btn_Enter->OnClicked().RemoveAll(this);
|
||||
bBound = false;
|
||||
}
|
||||
Super::NativeDestruct();
|
||||
}
|
||||
|
||||
void UUIServerCard_Base::SetFromEntry(const FZMMOWorldEntry& InEntry)
|
||||
{
|
||||
Entry = InEntry;
|
||||
|
||||
if (Text_Name)
|
||||
{
|
||||
Text_Name->SetText(FText::FromString(Entry.WorldName));
|
||||
}
|
||||
if (Text_Pop)
|
||||
{
|
||||
Text_Pop->SetText(FText::FromString(FString::Printf(TEXT("%d/%d"), Entry.Population, Entry.Capacity)));
|
||||
}
|
||||
if (Text_Status)
|
||||
{
|
||||
Text_Status->SetText(FormatStatus(Entry.State));
|
||||
}
|
||||
if (Bar_Pop)
|
||||
{
|
||||
const float Ratio = (Entry.Capacity > 0)
|
||||
? FMath::Clamp(static_cast<float>(Entry.Population) / static_cast<float>(Entry.Capacity), 0.f, 1.f)
|
||||
: 0.f;
|
||||
Bar_Pop->SetPercent(Ratio);
|
||||
}
|
||||
|
||||
// Botao "Selecionar" sempre habilitado — mesmo com world offline,
|
||||
// o usuario pode entrar no Lobby pra ver a lista de personagens, criar/
|
||||
// deletar. O gate de "entrar no mundo" fica no proprio Lobby (botao
|
||||
// "Entrar no Servidor"), que checa World.State antes do handoff.
|
||||
if (Btn_Enter)
|
||||
{
|
||||
Btn_Enter->RefreshUIStyle();
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerCard_Base::HandleEnterClicked()
|
||||
{
|
||||
OnCardPressed.Broadcast(Entry.WorldId, Entry.State);
|
||||
}
|
||||
|
||||
FText UUIServerCard_Base::FormatStatus_Implementation(uint8 State) const
|
||||
{
|
||||
switch (State)
|
||||
{
|
||||
case 1: return NSLOCTEXT("ZMMO.ServerSelect", "StatusOnline", "Online");
|
||||
case 2: return NSLOCTEXT("ZMMO.ServerSelect", "StatusMaintenance", "Manutencao");
|
||||
case 0:
|
||||
default: return NSLOCTEXT("ZMMO.ServerSelect", "StatusOffline", "Offline");
|
||||
}
|
||||
}
|
||||
74
Source/ZMMO/Game/UI/FrontEnd/UIServerCard_Base.h
Normal file
74
Source/ZMMO/Game/UI/FrontEnd/UIServerCard_Base.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "Blueprint/UserWidget.h"
|
||||
#include "ZMMOWorldEntry.h"
|
||||
#include "UIServerCard_Base.generated.h"
|
||||
|
||||
class UCommonTextBlock;
|
||||
class UBorder;
|
||||
class UUIButton_Base;
|
||||
class UProgressBar;
|
||||
|
||||
/**
|
||||
* Card de servidor (mundo) instanciado dinamicamente pela ServerSelect.
|
||||
*
|
||||
* Recebe um `FZMMOWorldEntry` e renderiza nome/pop/status/bar; ao clicar
|
||||
* em "Entrar" dispara `OnCardPressed.Broadcast(WorldId, State)`. A tela
|
||||
* dona (UUIServerSelectScreen_Base) escuta esse delegate.
|
||||
*
|
||||
* WBP filho (`WBP_ServerCard`) precisa expor (via nomes de variavel):
|
||||
* - Card_Bg : Border (opcional - background com estado)
|
||||
* - Text_Name : CommonTextBlock
|
||||
* - Text_Pop : CommonTextBlock
|
||||
* - Text_Status : CommonTextBlock
|
||||
* - Btn_Enter : Button
|
||||
* - Bar_Pop : ProgressBar (opcional)
|
||||
*/
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FZMMOOnServerCardPressed, FString, WorldId, uint8, State);
|
||||
|
||||
UCLASS(Abstract, Blueprintable)
|
||||
class ZMMO_API UUIServerCard_Base : public UUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Aplica os dados de um mundo no card.
|
||||
*/
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|ServerCard")
|
||||
void SetFromEntry(const FZMMOWorldEntry& Entry);
|
||||
|
||||
/** Dados atuais (copia). */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|ServerCard")
|
||||
FZMMOWorldEntry Entry;
|
||||
|
||||
/**
|
||||
* Disparado quando o botao "Entrar" do card e clicado.
|
||||
* State e o wire state (0=Offline, 1=Online, 2=Maintenance).
|
||||
*/
|
||||
UPROPERTY(BlueprintAssignable, Category = "Zeus|ServerCard")
|
||||
FZMMOOnServerCardPressed OnCardPressed;
|
||||
|
||||
protected:
|
||||
virtual void NativePreConstruct() override;
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
void HandleEnterClicked();
|
||||
|
||||
/** Texto i18n correspondente ao state. Subclasse pode override em BP. */
|
||||
UFUNCTION(BlueprintNativeEvent, Category = "Zeus|ServerCard")
|
||||
FText FormatStatus(uint8 State) const;
|
||||
virtual FText FormatStatus_Implementation(uint8 State) const;
|
||||
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UBorder> Card_Bg;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Name;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Pop;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Status;
|
||||
UPROPERTY(meta = (BindWidget)) TObjectPtr<UUIButton_Base> Btn_Enter;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UProgressBar> Bar_Pop;
|
||||
|
||||
private:
|
||||
bool bBound = false;
|
||||
};
|
||||
360
Source/ZMMO/Game/UI/FrontEnd/UIServerSelectScreen_Base.cpp
Normal file
360
Source/ZMMO/Game/UI/FrontEnd/UIServerSelectScreen_Base.cpp
Normal file
@@ -0,0 +1,360 @@
|
||||
#include "UIServerSelectScreen_Base.h"
|
||||
|
||||
#include "ZMMO.h"
|
||||
#include "ZMMOThemeSubsystem.h"
|
||||
#include "CharServerOpcodes.h"
|
||||
#include "UIFrontEndFlowSubsystem.h"
|
||||
#include "UIServerCard_Base.h"
|
||||
#include "ZeusCharServerSubsystem.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "Components/Border.h"
|
||||
#include "Components/GridPanel.h"
|
||||
#include "Components/GridSlot.h"
|
||||
#include "Components/PanelWidget.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/DataTable.h"
|
||||
#include "UI/UIStyleRow.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
FUIStyle ResolveServerSelectStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
{
|
||||
if (Widget)
|
||||
{
|
||||
if (const UGameInstance* GI = Widget->GetGameInstance())
|
||||
{
|
||||
if (const UZMMOThemeSubsystem* Theme = GI->GetSubsystem<UZMMOThemeSubsystem>())
|
||||
{
|
||||
return Theme->GetActiveUIStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
FUIStyle DesignStyle;
|
||||
bool bHas = false;
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UIServerSelectDesign"), false))
|
||||
{
|
||||
DesignStyle = Row->Style;
|
||||
bHas = true;
|
||||
}
|
||||
}
|
||||
return bHas ? DesignStyle : Fallback;
|
||||
}
|
||||
|
||||
// ---- Wire reader (LE) ----
|
||||
bool ReadU8(const TArray<uint8>& Buf, int32& Pos, uint8& Out)
|
||||
{
|
||||
if (Pos + 1 > Buf.Num()) return false;
|
||||
Out = Buf[Pos];
|
||||
Pos += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadU16(const TArray<uint8>& Buf, int32& Pos, uint16& Out)
|
||||
{
|
||||
if (Pos + 2 > Buf.Num()) return false;
|
||||
Out = static_cast<uint16>(Buf[Pos]) | (static_cast<uint16>(Buf[Pos + 1]) << 8);
|
||||
Pos += 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReadStringUtf8(const TArray<uint8>& Buf, int32& Pos, FString& Out)
|
||||
{
|
||||
uint16 Len = 0;
|
||||
if (!ReadU16(Buf, Pos, Len)) return false;
|
||||
if (Pos + Len > Buf.Num()) return false;
|
||||
if (Len == 0)
|
||||
{
|
||||
Out.Reset();
|
||||
}
|
||||
else
|
||||
{
|
||||
Out = FString(FUTF8ToTCHAR(reinterpret_cast<const ANSICHAR*>(Buf.GetData() + Pos), Len));
|
||||
}
|
||||
Pos += Len;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::NativePreConstruct()
|
||||
{
|
||||
Super::NativePreConstruct();
|
||||
RefreshUIStyle();
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::NativeOnActivated()
|
||||
{
|
||||
Super::NativeOnActivated();
|
||||
UE_LOG(LogZMMO, Log, TEXT("ServerSelect: ativada."));
|
||||
|
||||
if (UZeusCharServerSubsystem* Char = GetCharServer())
|
||||
{
|
||||
if (!bSubscribed)
|
||||
{
|
||||
Char->OnRawMessage.AddDynamic(this, &UUIServerSelectScreen_Base::HandleCharRawMessage);
|
||||
bSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
RequestWorldList();
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::NativeOnDeactivated()
|
||||
{
|
||||
if (bSubscribed)
|
||||
{
|
||||
if (UZeusCharServerSubsystem* Char = GetCharServer())
|
||||
{
|
||||
Char->OnRawMessage.RemoveDynamic(this, &UUIServerSelectScreen_Base::HandleCharRawMessage);
|
||||
}
|
||||
bSubscribed = false;
|
||||
}
|
||||
Super::NativeOnDeactivated();
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::RefreshUIStyle_Implementation()
|
||||
{
|
||||
Super::RefreshUIStyle_Implementation();
|
||||
|
||||
const FUIStyle Fallback;
|
||||
const FUIStyle AS = ResolveServerSelectStyle(this, Fallback);
|
||||
|
||||
if (Border_Bg)
|
||||
{
|
||||
if (AS.ScreenBackground.GetResourceObject() != nullptr)
|
||||
{
|
||||
Border_Bg->SetBrush(AS.ScreenBackground);
|
||||
}
|
||||
else
|
||||
{
|
||||
Border_Bg->SetBrushColor(AS.Palette.Bg0);
|
||||
}
|
||||
}
|
||||
if (Text_Title)
|
||||
{
|
||||
Text_Title->SetColorAndOpacity(FSlateColor(AS.Semantic.OnSurface));
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::RequestWorldList()
|
||||
{
|
||||
UZeusCharServerSubsystem* Char = GetCharServer();
|
||||
if (!Char || !Char->IsConnected())
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: CharServer indisponivel — pulando RequestWorldList"));
|
||||
return;
|
||||
}
|
||||
TArray<uint8> EmptyPayload;
|
||||
Char->SendCharRequest(ZMMOCharOp::C_WORLD_LIST_REQUEST, EmptyPayload);
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::SelectWorldAndProceed(const FString& WorldId)
|
||||
{
|
||||
if (WorldId.IsEmpty())
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: SelectWorldAndProceed com WorldId vazio."));
|
||||
return;
|
||||
}
|
||||
UGameInstance* GI = GetGameInstance();
|
||||
if (!GI)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (UUIFrontEndFlowSubsystem* Flow = GI->GetSubsystem<UUIFrontEndFlowSubsystem>())
|
||||
{
|
||||
Flow->SetSelectedWorldId(WorldId);
|
||||
Flow->SetState(EZMMOFrontEndState::Lobby);
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::HandleCharRawMessage(int32 Opcode, const TArray<uint8>& Payload)
|
||||
{
|
||||
if (Opcode == ZMMOCharOp::S_WORLD_LIST)
|
||||
{
|
||||
ParseWorldList(Payload);
|
||||
}
|
||||
else if (Opcode == ZMMOCharOp::S_WORLD_STATUS_UPDATE)
|
||||
{
|
||||
ApplyStatusUpdate(Payload);
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::ApplyStatusUpdate(const TArray<uint8>& Payload)
|
||||
{
|
||||
// Wire: string worldId + uint16 pop + uint16 queueLen + uint8 state
|
||||
int32 Pos = 0;
|
||||
FString WorldId;
|
||||
uint16 Pop = 0;
|
||||
uint16 QueueLen = 0;
|
||||
uint8 State = 0;
|
||||
if (!ReadStringUtf8(Payload, Pos, WorldId)
|
||||
|| !ReadU16(Payload, Pos, Pop)
|
||||
|| !ReadU16(Payload, Pos, QueueLen)
|
||||
|| !ReadU8(Payload, Pos, State))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: S_WORLD_STATUS_UPDATE malformado"));
|
||||
return;
|
||||
}
|
||||
int32 Idx = INDEX_NONE;
|
||||
for (int32 i = 0; i < Worlds.Num(); ++i)
|
||||
{
|
||||
if (Worlds[i].WorldId == WorldId)
|
||||
{
|
||||
Idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (Idx == INDEX_NONE)
|
||||
{
|
||||
// Mundo desconhecido (ainda nao recebemos via S_WORLD_LIST). Pede lista
|
||||
// completa pra trazer dados estaticos (name/host/region/capacity).
|
||||
UE_LOG(LogZMMO, Log, TEXT("ServerSelect: STATUS_UPDATE de mundo desconhecido (%s) — pedindo lista"), *WorldId);
|
||||
RequestWorldList();
|
||||
return;
|
||||
}
|
||||
FZMMOWorldEntry& Entry = Worlds[Idx];
|
||||
Entry.Population = static_cast<int32>(Pop);
|
||||
Entry.QueueLen = static_cast<int32>(QueueLen);
|
||||
Entry.State = State;
|
||||
UE_LOG(LogZMMO, Log, TEXT("ServerSelect: world %s atualizado (state=%d pop=%d)"),
|
||||
*WorldId, State, Entry.Population);
|
||||
// Atualiza apenas o card afetado (preserva animacoes/scroll dos demais).
|
||||
if (SpawnedCards.IsValidIndex(Idx) && SpawnedCards[Idx])
|
||||
{
|
||||
SpawnedCards[Idx]->SetFromEntry(Entry);
|
||||
}
|
||||
OnWorldListReceived();
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::ParseWorldList(const TArray<uint8>& Payload)
|
||||
{
|
||||
int32 Pos = 0;
|
||||
uint16 Count = 0;
|
||||
if (!ReadU16(Payload, Pos, Count))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: S_WORLD_LIST malformado (sem count)"));
|
||||
return;
|
||||
}
|
||||
|
||||
TArray<FZMMOWorldEntry> NewWorlds;
|
||||
NewWorlds.Reserve(Count);
|
||||
|
||||
for (uint16 i = 0; i < Count; ++i)
|
||||
{
|
||||
FZMMOWorldEntry Entry;
|
||||
FString RegionStr;
|
||||
uint16 Port = 0;
|
||||
uint16 Cap = 0;
|
||||
uint16 Pop = 0;
|
||||
uint16 QueueLen = 0;
|
||||
uint8 State = 0;
|
||||
|
||||
if (!ReadStringUtf8(Payload, Pos, Entry.WorldId)
|
||||
|| !ReadStringUtf8(Payload, Pos, Entry.WorldName)
|
||||
|| !ReadStringUtf8(Payload, Pos, RegionStr)
|
||||
|| !ReadStringUtf8(Payload, Pos, Entry.Host)
|
||||
|| !ReadU16(Payload, Pos, Port)
|
||||
|| !ReadU16(Payload, Pos, Cap)
|
||||
|| !ReadU16(Payload, Pos, Pop)
|
||||
|| !ReadU16(Payload, Pos, QueueLen)
|
||||
|| !ReadU8(Payload, Pos, State))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: S_WORLD_LIST malformado (entry %d)"), i);
|
||||
return;
|
||||
}
|
||||
Entry.Region = RegionStr;
|
||||
Entry.Port = static_cast<int32>(Port);
|
||||
Entry.Capacity = static_cast<int32>(Cap);
|
||||
Entry.Population = static_cast<int32>(Pop);
|
||||
Entry.QueueLen = static_cast<int32>(QueueLen);
|
||||
Entry.State = State;
|
||||
NewWorlds.Add(MoveTemp(Entry));
|
||||
}
|
||||
|
||||
Worlds = MoveTemp(NewWorlds);
|
||||
UE_LOG(LogZMMO, Log, TEXT("ServerSelect: recebidos %d mundos"), Worlds.Num());
|
||||
|
||||
RebuildCards();
|
||||
OnWorldListReceived();
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::RebuildCards()
|
||||
{
|
||||
if (!CardContainer)
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: CardContainer nao bindado — cards nao serao exibidos"));
|
||||
return;
|
||||
}
|
||||
if (!CardClass)
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: CardClass nao configurada — defina no WBP_ServerSelect Defaults"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpa cards antigos (delegate + remove do container).
|
||||
for (UUIServerCard_Base* Old : SpawnedCards)
|
||||
{
|
||||
if (Old)
|
||||
{
|
||||
Old->OnCardPressed.RemoveDynamic(this, &UUIServerSelectScreen_Base::HandleCardPressed);
|
||||
}
|
||||
}
|
||||
SpawnedCards.Reset();
|
||||
CardContainer->ClearChildren();
|
||||
|
||||
UGridPanel* AsGrid = Cast<UGridPanel>(CardContainer);
|
||||
const int32 GridColumns = 2; // bate com o mock (ColumnFill[0]/[1])
|
||||
|
||||
int32 Index = 0;
|
||||
for (const FZMMOWorldEntry& W : Worlds)
|
||||
{
|
||||
UUIServerCard_Base* Card = CreateWidget<UUIServerCard_Base>(this, CardClass);
|
||||
if (!Card)
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("ServerSelect: falha ao criar card para %s"), *W.WorldName);
|
||||
continue;
|
||||
}
|
||||
Card->SetFromEntry(W);
|
||||
Card->OnCardPressed.AddDynamic(this, &UUIServerSelectScreen_Base::HandleCardPressed);
|
||||
CardContainer->AddChild(Card);
|
||||
|
||||
// Quando o container e um GridPanel, distribui em grid (row, col) — bate
|
||||
// com o layout do mock (2 colunas, N linhas).
|
||||
if (AsGrid)
|
||||
{
|
||||
if (UGridSlot* GS = Cast<UGridSlot>(Card->Slot))
|
||||
{
|
||||
GS->SetRow(Index / GridColumns);
|
||||
GS->SetColumn(Index % GridColumns);
|
||||
GS->SetHorizontalAlignment(HAlign_Fill);
|
||||
GS->SetVerticalAlignment(VAlign_Fill);
|
||||
GS->SetPadding(FMargin(12.f, 12.f, 12.f, 12.f));
|
||||
}
|
||||
}
|
||||
|
||||
SpawnedCards.Add(Card);
|
||||
++Index;
|
||||
}
|
||||
}
|
||||
|
||||
void UUIServerSelectScreen_Base::HandleCardPressed(FString WorldId, uint8 State)
|
||||
{
|
||||
// O ServerSelect permite escolher qualquer mundo (online/maintenance/
|
||||
// offline) — o usuario entra no Lobby pra gerenciar chars desse mundo.
|
||||
// O bloqueio real (handoff -> world) acontece no botao "Entrar no
|
||||
// Servidor" do Lobby, que checa World.State antes do C_CHAR_SELECT.
|
||||
UE_LOG(LogZMMO, Log, TEXT("ServerSelect: mundo %s selecionado (state=%d)"), *WorldId, State);
|
||||
SelectWorldAndProceed(WorldId);
|
||||
}
|
||||
|
||||
UZeusCharServerSubsystem* UUIServerSelectScreen_Base::GetCharServer() const
|
||||
{
|
||||
if (const UGameInstance* GI = GetGameInstance())
|
||||
{
|
||||
return GI->GetSubsystem<UZeusCharServerSubsystem>();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
82
Source/ZMMO/Game/UI/FrontEnd/UIServerSelectScreen_Base.h
Normal file
82
Source/ZMMO/Game/UI/FrontEnd/UIServerSelectScreen_Base.h
Normal file
@@ -0,0 +1,82 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UIActivatableScreen_Base.h"
|
||||
#include "ZMMOWorldEntry.h"
|
||||
#include "UIServerSelectScreen_Base.generated.h"
|
||||
|
||||
class UCommonTextBlock;
|
||||
class UBorder;
|
||||
class UPanelWidget;
|
||||
class UUIServerCard_Base;
|
||||
class UZeusCharServerSubsystem;
|
||||
|
||||
/**
|
||||
* Tela de Server Select — Fase 1 do ARQUITETURA_SERVER_SELECT.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. NativeOnActivated subscreve OnRawMessage do UZeusCharServerSubsystem
|
||||
* 2. Envia C_WORLD_LIST_REQUEST (payload vazio)
|
||||
* 3. ParseWorldList monta `Worlds`
|
||||
* 4. RebuildCards apaga filhos de `CardContainer` e instancia 1 WBP_ServerCard
|
||||
* por mundo, bindando `OnCardPressed -> SelectWorldAndProceed`
|
||||
* 5. Dispara `OnWorldListReceived` (BIE) pra extensoes em BP
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable)
|
||||
class ZMMO_API UUIServerSelectScreen_Base : public UUIActivatableScreen_Base
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|ServerSelect")
|
||||
void RequestWorldList();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|ServerSelect")
|
||||
void SelectWorldAndProceed(const FString& WorldId);
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|ServerSelect")
|
||||
TArray<FZMMOWorldEntry> Worlds;
|
||||
|
||||
UFUNCTION(BlueprintImplementableEvent, Category = "Zeus|ServerSelect")
|
||||
void OnWorldListReceived();
|
||||
|
||||
protected:
|
||||
virtual void NativePreConstruct() override;
|
||||
virtual void NativeOnActivated() override;
|
||||
virtual void NativeOnDeactivated() override;
|
||||
virtual void RefreshUIStyle_Implementation() override;
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCharRawMessage(int32 Opcode, const TArray<uint8>& Payload);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCardPressed(FString WorldId, uint8 State);
|
||||
|
||||
UZeusCharServerSubsystem* GetCharServer() const;
|
||||
void ParseWorldList(const TArray<uint8>& Payload);
|
||||
void ApplyStatusUpdate(const TArray<uint8>& Payload);
|
||||
void RebuildCards();
|
||||
|
||||
/** Background e titulo (mantidos para RefreshUIStyle). */
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UBorder> Border_Bg;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Title;
|
||||
|
||||
/**
|
||||
* Container onde os cards sao instanciados (ScrollBox/VerticalBox/
|
||||
* UniformGridPanel/WrapBox — qualquer UPanelWidget serve).
|
||||
*/
|
||||
UPROPERTY(meta = (BindWidget)) TObjectPtr<UPanelWidget> CardContainer;
|
||||
|
||||
/**
|
||||
* Classe do card a instanciar. Configurada no WBP_ServerSelect
|
||||
* (Defaults) ou via Project Settings se for global.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Zeus|ServerSelect")
|
||||
TSubclassOf<UUIServerCard_Base> CardClass;
|
||||
|
||||
private:
|
||||
bool bSubscribed = false;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UUIServerCard_Base>> SpawnedCards;
|
||||
};
|
||||
468
Source/ZMMO/Game/UI/FrontEnd/UIUserLobbyScreen_Base.cpp
Normal file
468
Source/ZMMO/Game/UI/FrontEnd/UIUserLobbyScreen_Base.cpp
Normal file
@@ -0,0 +1,468 @@
|
||||
#include "UIUserLobbyScreen_Base.h"
|
||||
|
||||
#include "ZMMO.h"
|
||||
#include "CharServerOpcodes.h"
|
||||
#include "UIFrontEndFlowSubsystem.h"
|
||||
#include "UICharCard_Base.h"
|
||||
#include "UICharacterCreatePage_Base.h"
|
||||
#include "ZeusCharServerSubsystem.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "Components/PanelWidget.h"
|
||||
#include "Components/WidgetSwitcher.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "UI/Widgets/UIButton_Base.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
bool ReadU8(const TArray<uint8>& Buf, int32& Pos, uint8& Out)
|
||||
{
|
||||
if (Pos + 1 > Buf.Num()) return false;
|
||||
Out = Buf[Pos]; Pos += 1; return true;
|
||||
}
|
||||
bool ReadU16(const TArray<uint8>& Buf, int32& Pos, uint16& Out)
|
||||
{
|
||||
if (Pos + 2 > Buf.Num()) return false;
|
||||
Out = static_cast<uint16>(Buf[Pos]) | (static_cast<uint16>(Buf[Pos + 1]) << 8);
|
||||
Pos += 2; return true;
|
||||
}
|
||||
bool ReadU32(const TArray<uint8>& Buf, int32& Pos, uint32& Out)
|
||||
{
|
||||
if (Pos + 4 > Buf.Num()) return false;
|
||||
Out = static_cast<uint32>(Buf[Pos])
|
||||
| (static_cast<uint32>(Buf[Pos + 1]) << 8)
|
||||
| (static_cast<uint32>(Buf[Pos + 2]) << 16)
|
||||
| (static_cast<uint32>(Buf[Pos + 3]) << 24);
|
||||
Pos += 4; return true;
|
||||
}
|
||||
bool ReadU64(const TArray<uint8>& Buf, int32& Pos, uint64& Out)
|
||||
{
|
||||
if (Pos + 8 > Buf.Num()) return false;
|
||||
Out = 0;
|
||||
for (int i = 0; i < 8; ++i) Out |= (static_cast<uint64>(Buf[Pos + i]) << (8 * i));
|
||||
Pos += 8; return true;
|
||||
}
|
||||
bool ReadFloat(const TArray<uint8>& Buf, int32& Pos, float& Out)
|
||||
{
|
||||
uint32 raw;
|
||||
if (!ReadU32(Buf, Pos, raw)) return false;
|
||||
FMemory::Memcpy(&Out, &raw, sizeof(float));
|
||||
return true;
|
||||
}
|
||||
bool ReadStringUtf8(const TArray<uint8>& Buf, int32& Pos, FString& Out)
|
||||
{
|
||||
uint16 Len = 0;
|
||||
if (!ReadU16(Buf, Pos, Len)) return false;
|
||||
if (Pos + Len > Buf.Num()) return false;
|
||||
if (Len == 0) { Out.Reset(); return true; }
|
||||
Out = FString(FUTF8ToTCHAR(reinterpret_cast<const ANSICHAR*>(Buf.GetData() + Pos), Len));
|
||||
Pos += Len;
|
||||
return true;
|
||||
}
|
||||
bool ReadUuid16(const TArray<uint8>& Buf, int32& Pos, FString& Out)
|
||||
{
|
||||
if (Pos + 16 > Buf.Num()) return false;
|
||||
// 16 bytes raw -> 8-4-4-4-12
|
||||
auto Hex = [](uint8 b, ANSICHAR* dst)
|
||||
{
|
||||
static const ANSICHAR* H = "0123456789abcdef";
|
||||
dst[0] = H[(b >> 4) & 0xF];
|
||||
dst[1] = H[b & 0xF];
|
||||
};
|
||||
ANSICHAR raw[37]; raw[36] = 0;
|
||||
int32 j = 0;
|
||||
for (int32 i = 0; i < 16; ++i)
|
||||
{
|
||||
if (i == 4 || i == 6 || i == 8 || i == 10) raw[j++] = '-';
|
||||
Hex(Buf[Pos + i], &raw[j]); j += 2;
|
||||
}
|
||||
Out = ANSI_TO_TCHAR(raw);
|
||||
Pos += 16;
|
||||
return true;
|
||||
}
|
||||
bool WriteUuid16(TArray<uint8>& Buf, const FString& Uuid)
|
||||
{
|
||||
// Espera xxxxxxxx-xxxx-...
|
||||
FString Hex = Uuid.Replace(TEXT("-"), TEXT(""));
|
||||
if (Hex.Len() != 32) return false;
|
||||
for (int32 i = 0; i < 16; ++i)
|
||||
{
|
||||
TCHAR hi = Hex[i * 2];
|
||||
TCHAR lo = Hex[i * 2 + 1];
|
||||
auto HexVal = [](TCHAR c) -> int32 {
|
||||
if (c >= TEXT('0') && c <= TEXT('9')) return c - TEXT('0');
|
||||
if (c >= TEXT('a') && c <= TEXT('f')) return c - TEXT('a') + 10;
|
||||
if (c >= TEXT('A') && c <= TEXT('F')) return c - TEXT('A') + 10;
|
||||
return -1;
|
||||
};
|
||||
int32 H = HexVal(hi);
|
||||
int32 L = HexVal(lo);
|
||||
if (H < 0 || L < 0) return false;
|
||||
Buf.Add(static_cast<uint8>((H << 4) | L));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::NativeOnActivated()
|
||||
{
|
||||
Super::NativeOnActivated();
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: ativada."));
|
||||
|
||||
if (UZeusCharServerSubsystem* Char = GetCharServer())
|
||||
{
|
||||
if (!bSubscribed)
|
||||
{
|
||||
Char->OnRawMessage.AddDynamic(this, &UUIUserLobbyScreen_Base::HandleCharRawMessage);
|
||||
bSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bButtonsBound)
|
||||
{
|
||||
if (Btn_Back) Btn_Back->OnClicked().AddUObject(this, &UUIUserLobbyScreen_Base::BackToServerSelect);
|
||||
if (Btn_Create) Btn_Create->OnClicked().AddUObject(this, &UUIUserLobbyScreen_Base::ShowCharCreatePage);
|
||||
bButtonsBound = true;
|
||||
}
|
||||
|
||||
ShowCharSelectPage();
|
||||
RequestCharList();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::NativeOnDeactivated()
|
||||
{
|
||||
if (bSubscribed)
|
||||
{
|
||||
if (UZeusCharServerSubsystem* Char = GetCharServer())
|
||||
{
|
||||
Char->OnRawMessage.RemoveDynamic(this, &UUIUserLobbyScreen_Base::HandleCharRawMessage);
|
||||
}
|
||||
bSubscribed = false;
|
||||
}
|
||||
Super::NativeOnDeactivated();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::RequestCharList()
|
||||
{
|
||||
UZeusCharServerSubsystem* Char = GetCharServer();
|
||||
if (!Char || !Char->IsConnected()) return;
|
||||
|
||||
FString WorldId;
|
||||
if (UGameInstance* GI = GetGameInstance())
|
||||
{
|
||||
if (UUIFrontEndFlowSubsystem* Flow = GI->GetSubsystem<UUIFrontEndFlowSubsystem>())
|
||||
{
|
||||
WorldId = Flow->GetSelectedWorldId();
|
||||
}
|
||||
}
|
||||
|
||||
// Wire: uint8 hasFilter + (uint8[16] worldId se hasFilter==1)
|
||||
TArray<uint8> Payload;
|
||||
if (WorldId.IsEmpty())
|
||||
{
|
||||
Payload.Add(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
Payload.Add(1);
|
||||
if (!WriteUuid16(Payload, WorldId))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: SelectedWorldId invalido (%s) — pedindo sem filtro"), *WorldId);
|
||||
Payload.Reset();
|
||||
Payload.Add(0);
|
||||
}
|
||||
}
|
||||
Char->SendCharRequest(ZMMOCharOp::C_CHAR_LIST_REQUEST, Payload);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::ShowCharSelectPage()
|
||||
{
|
||||
if (PageSwitcher) PageSwitcher->SetActiveWidgetIndex(0);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::ShowCharCreatePage()
|
||||
{
|
||||
if (PageSwitcher) PageSwitcher->SetActiveWidgetIndex(1);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::BackToServerSelect()
|
||||
{
|
||||
UGameInstance* GI = GetGameInstance();
|
||||
if (!GI) return;
|
||||
if (UUIFrontEndFlowSubsystem* Flow = GI->GetSubsystem<UUIFrontEndFlowSubsystem>())
|
||||
{
|
||||
Flow->ClearSelectedWorld();
|
||||
Flow->SetState(EZMMOFrontEndState::ServerSelect);
|
||||
}
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharRawMessage(int32 Opcode, const TArray<uint8>& Payload)
|
||||
{
|
||||
switch (Opcode)
|
||||
{
|
||||
case ZMMOCharOp::S_CHAR_LIST: ParseCharList(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_SELECT_OK: HandleCharSelectOk(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_SELECT_REJECT: HandleCharSelectReject(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_CREATE_OK: HandleCharCreateOk(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_CREATE_REJECT: HandleCharCreateReject(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_DELETE_ACK: HandleCharDeleteAck(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_DELETE_ACCEPT_ACK: HandleCharDeleteAcceptAck(Payload); break;
|
||||
case ZMMOCharOp::S_CHAR_DELETE_CANCEL_ACK: HandleCharDeleteCancelAck(Payload); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::ParseCharList(const TArray<uint8>& Payload)
|
||||
{
|
||||
int32 Pos = 0;
|
||||
uint16 Count = 0;
|
||||
if (!ReadU16(Payload, Pos, Count))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: S_CHAR_LIST malformado (sem count)"));
|
||||
return;
|
||||
}
|
||||
|
||||
TArray<FZMMOCharSummary> New;
|
||||
New.Reserve(Count);
|
||||
|
||||
for (uint16 i = 0; i < Count; ++i)
|
||||
{
|
||||
FZMMOCharSummary E;
|
||||
uint64 charId64 = 0;
|
||||
uint8 slot = 0;
|
||||
uint16 classId = 0;
|
||||
uint32 baseLvl = 0, baseExp = 0, jobLvl = 0, jobExp = 0;
|
||||
uint16 s = 0, a = 0, v = 0, in_ = 0, d = 0, l = 0;
|
||||
uint32 hp = 0, maxHp = 0, sp = 0, maxSp = 0, money = 0;
|
||||
float px = 0, py = 0, pz = 0, yaw = 0;
|
||||
uint32 hair = 0, hairColor = 0, skinColor = 0;
|
||||
uint8 bodyType = 0;
|
||||
uint64 deleteAt = 0;
|
||||
|
||||
if (!ReadU64(Payload, Pos, charId64)
|
||||
|| !ReadUuid16(Payload, Pos, E.WorldId)
|
||||
|| !ReadU8(Payload, Pos, slot)
|
||||
|| !ReadStringUtf8(Payload, Pos, E.Name)
|
||||
|| !ReadU16(Payload, Pos, classId)
|
||||
|| !ReadU32(Payload, Pos, baseLvl)
|
||||
|| !ReadU32(Payload, Pos, baseExp)
|
||||
|| !ReadU32(Payload, Pos, jobLvl)
|
||||
|| !ReadU32(Payload, Pos, jobExp)
|
||||
|| !ReadU16(Payload, Pos, s) || !ReadU16(Payload, Pos, a) || !ReadU16(Payload, Pos, v)
|
||||
|| !ReadU16(Payload, Pos, in_) || !ReadU16(Payload, Pos, d) || !ReadU16(Payload, Pos, l)
|
||||
|| !ReadU32(Payload, Pos, hp) || !ReadU32(Payload, Pos, maxHp)
|
||||
|| !ReadU32(Payload, Pos, sp) || !ReadU32(Payload, Pos, maxSp)
|
||||
|| !ReadU32(Payload, Pos, money)
|
||||
|| !ReadStringUtf8(Payload, Pos, E.MapName)
|
||||
|| !ReadFloat(Payload, Pos, px) || !ReadFloat(Payload, Pos, py) || !ReadFloat(Payload, Pos, pz)
|
||||
|| !ReadFloat(Payload, Pos, yaw)
|
||||
|| !ReadU32(Payload, Pos, hair) || !ReadU32(Payload, Pos, hairColor) || !ReadU32(Payload, Pos, skinColor)
|
||||
|| !ReadU8(Payload, Pos, bodyType)
|
||||
|| !ReadU64(Payload, Pos, deleteAt))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: S_CHAR_LIST malformado (entry %d)"), i);
|
||||
return;
|
||||
}
|
||||
E.CharId = FString::Printf(TEXT("%llu"), static_cast<unsigned long long>(charId64));
|
||||
E.Slot = slot; E.ClassId = classId;
|
||||
E.BaseLevel = baseLvl; E.BaseExp = static_cast<int32>(baseExp);
|
||||
E.JobLevel = jobLvl; E.JobExp = static_cast<int32>(jobExp);
|
||||
E.Stats.Str = s; E.Stats.Agi = a; E.Stats.Vit = v;
|
||||
E.Stats.Int = in_; E.Stats.Dex = d; E.Stats.Luk = l;
|
||||
E.Hp = hp; E.MaxHp = maxHp; E.Sp = sp; E.MaxSp = maxSp;
|
||||
E.Money = static_cast<int32>(money);
|
||||
E.Position = FVector(px, py, pz); E.YawDeg = yaw;
|
||||
E.Appearance.Hair = hair; E.Appearance.HairColor = hairColor;
|
||||
E.Appearance.SkinColor = skinColor; E.Appearance.BodyType = bodyType;
|
||||
E.DeleteScheduledAtMs = static_cast<int64>(deleteAt);
|
||||
New.Add(MoveTemp(E));
|
||||
}
|
||||
|
||||
Chars = MoveTemp(New);
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: recebidos %d chars"), Chars.Num());
|
||||
RebuildCards();
|
||||
OnCharListReceived();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::RebuildCards()
|
||||
{
|
||||
if (!CardContainer || !CharCardClass) return;
|
||||
|
||||
for (UUICharCard_Base* Old : SpawnedCards)
|
||||
{
|
||||
if (Old)
|
||||
{
|
||||
Old->OnCardSelected.RemoveDynamic(this, &UUIUserLobbyScreen_Base::HandleCardSelected);
|
||||
Old->OnCardDeleteRequest.RemoveDynamic(this, &UUIUserLobbyScreen_Base::HandleCardDeleteRequest);
|
||||
Old->OnCardDeleteAccept.RemoveDynamic(this, &UUIUserLobbyScreen_Base::HandleCardAcceptDeleteRequest);
|
||||
Old->OnCardDeleteCancel.RemoveDynamic(this, &UUIUserLobbyScreen_Base::HandleCardCancelDeleteRequest);
|
||||
}
|
||||
}
|
||||
SpawnedCards.Reset();
|
||||
CardContainer->ClearChildren();
|
||||
|
||||
for (const FZMMOCharSummary& C : Chars)
|
||||
{
|
||||
UUICharCard_Base* Card = CreateWidget<UUICharCard_Base>(this, CharCardClass);
|
||||
if (!Card) continue;
|
||||
Card->SetFromSummary(C);
|
||||
Card->OnCardSelected.AddDynamic(this, &UUIUserLobbyScreen_Base::HandleCardSelected);
|
||||
Card->OnCardDeleteRequest.AddDynamic(this, &UUIUserLobbyScreen_Base::HandleCardDeleteRequest);
|
||||
Card->OnCardDeleteAccept.AddDynamic(this, &UUIUserLobbyScreen_Base::HandleCardAcceptDeleteRequest);
|
||||
Card->OnCardDeleteCancel.AddDynamic(this, &UUIUserLobbyScreen_Base::HandleCardCancelDeleteRequest);
|
||||
CardContainer->AddChild(Card);
|
||||
SpawnedCards.Add(Card);
|
||||
}
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCardSelected(FString CharId)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: char %s selecionado — enviando C_CHAR_SELECT"), *CharId);
|
||||
UZeusCharServerSubsystem* Char = GetCharServer();
|
||||
if (!Char) return;
|
||||
uint64 CharId64 = 0;
|
||||
LexFromString(CharId64, *CharId);
|
||||
// Wire: uint64 charId
|
||||
TArray<uint8> Payload;
|
||||
for (int32 i = 0; i < 8; ++i) Payload.Add(static_cast<uint8>((CharId64 >> (8 * i)) & 0xFF));
|
||||
Char->SendCharRequest(ZMMOCharOp::C_CHAR_SELECT, Payload);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCardDeleteRequest(FString CharId)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: DELETE_REQUEST char %s"), *CharId);
|
||||
SendCharIdOpcode(ZMMOCharOp::C_CHAR_DELETE_REQUEST, CharId);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCardAcceptDeleteRequest(FString CharId)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: DELETE_ACCEPT char %s"), *CharId);
|
||||
SendCharIdOpcode(ZMMOCharOp::C_CHAR_DELETE_ACCEPT, CharId);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCardCancelDeleteRequest(FString CharId)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: DELETE_CANCEL char %s"), *CharId);
|
||||
SendCharIdOpcode(ZMMOCharOp::C_CHAR_DELETE_CANCEL, CharId);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::SendCharIdOpcode(int32 Opcode, const FString& CharId)
|
||||
{
|
||||
UZeusCharServerSubsystem* Char = GetCharServer();
|
||||
if (!Char) return;
|
||||
uint64 CharId64 = 0;
|
||||
LexFromString(CharId64, *CharId);
|
||||
TArray<uint8> Payload;
|
||||
for (int32 i = 0; i < 8; ++i) Payload.Add(static_cast<uint8>((CharId64 >> (8 * i)) & 0xFF));
|
||||
Char->SendCharRequest(Opcode, Payload);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharDeleteAck(const TArray<uint8>& Payload)
|
||||
{
|
||||
// Wire: uint64 charId + uint16 reason + uint64 effectiveAtMs
|
||||
int32 Pos = 0; uint64 CharId64 = 0; uint16 Reason = 0; uint64 EffectiveAtMs = 0;
|
||||
if (!ReadU64(Payload, Pos, CharId64) || !ReadU16(Payload, Pos, Reason) || !ReadU64(Payload, Pos, EffectiveAtMs))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: S_CHAR_DELETE_ACK malformado"));
|
||||
return;
|
||||
}
|
||||
if (Reason == 0)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: delete agendado char=%llu (effective=%llu)"),
|
||||
static_cast<unsigned long long>(CharId64), static_cast<unsigned long long>(EffectiveAtMs));
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: DELETE_ACK rejeitado (reason=%d)"), Reason);
|
||||
}
|
||||
RequestCharList();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharDeleteAcceptAck(const TArray<uint8>& Payload)
|
||||
{
|
||||
int32 Pos = 0; uint64 CharId64 = 0; uint16 Reason = 0;
|
||||
ReadU64(Payload, Pos, CharId64); ReadU16(Payload, Pos, Reason);
|
||||
if (Reason == 0)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: char %llu DELETADO"), static_cast<unsigned long long>(CharId64));
|
||||
}
|
||||
else
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: DELETE_ACCEPT rejeitado (reason=%d)"), Reason);
|
||||
}
|
||||
RequestCharList();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharDeleteCancelAck(const TArray<uint8>& Payload)
|
||||
{
|
||||
int32 Pos = 0; uint64 CharId64 = 0; uint16 Reason = 0;
|
||||
ReadU64(Payload, Pos, CharId64); ReadU16(Payload, Pos, Reason);
|
||||
if (Reason == 0)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: delete cancelado char %llu"), static_cast<unsigned long long>(CharId64));
|
||||
}
|
||||
RequestCharList();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharSelectOk(const TArray<uint8>& Payload)
|
||||
{
|
||||
// Wire: uint64 charId + string mapName + float x/y/z + float yaw +
|
||||
// string worldHost + uint16 worldPort + string handoffToken + string region
|
||||
int32 Pos = 0;
|
||||
uint64 CharId64 = 0; FString MapName, WorldHost, HandoffToken, Region;
|
||||
float Px=0, Py=0, Pz=0, Yaw=0; uint16 WorldPort = 0;
|
||||
if (!ReadU64(Payload, Pos, CharId64)
|
||||
|| !ReadStringUtf8(Payload, Pos, MapName)
|
||||
|| !ReadFloat(Payload, Pos, Px) || !ReadFloat(Payload, Pos, Py) || !ReadFloat(Payload, Pos, Pz)
|
||||
|| !ReadFloat(Payload, Pos, Yaw)
|
||||
|| !ReadStringUtf8(Payload, Pos, WorldHost)
|
||||
|| !ReadU16(Payload, Pos, WorldPort)
|
||||
|| !ReadStringUtf8(Payload, Pos, HandoffToken)
|
||||
|| !ReadStringUtf8(Payload, Pos, Region))
|
||||
{
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: S_CHAR_SELECT_OK malformado"));
|
||||
return;
|
||||
}
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: handoff -> world=%s:%d map=%s token=%s..."),
|
||||
*WorldHost, WorldPort, *MapName, *HandoffToken.Left(8));
|
||||
|
||||
UGameInstance* GI = GetGameInstance();
|
||||
if (!GI) return;
|
||||
if (UUIFrontEndFlowSubsystem* Flow = GI->GetSubsystem<UUIFrontEndFlowSubsystem>())
|
||||
{
|
||||
Flow->SetState(EZMMOFrontEndState::EnteringWorld);
|
||||
}
|
||||
// TODO Fase 1.5C: invocar UZeusNetworkSubsystem->ConnectToWorld(host,port,token)
|
||||
// quando o WorldServer aceitar tickets do CharServer (handshake estendido).
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharSelectReject(const TArray<uint8>& Payload)
|
||||
{
|
||||
int32 Pos = 0;
|
||||
uint16 Reason = 0;
|
||||
ReadU16(Payload, Pos, Reason);
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: S_CHAR_SELECT_REJECT reason=%d"), Reason);
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharCreateOk(const TArray<uint8>& /*Payload*/)
|
||||
{
|
||||
UE_LOG(LogZMMO, Log, TEXT("Lobby: S_CHAR_CREATE_OK — refresh lista"));
|
||||
ShowCharSelectPage();
|
||||
RequestCharList();
|
||||
}
|
||||
|
||||
void UUIUserLobbyScreen_Base::HandleCharCreateReject(const TArray<uint8>& Payload)
|
||||
{
|
||||
int32 Pos = 0;
|
||||
uint16 Reason = 0;
|
||||
ReadU16(Payload, Pos, Reason);
|
||||
UE_LOG(LogZMMO, Warning, TEXT("Lobby: S_CHAR_CREATE_REJECT reason=%d"), Reason);
|
||||
}
|
||||
|
||||
UZeusCharServerSubsystem* UUIUserLobbyScreen_Base::GetCharServer() const
|
||||
{
|
||||
if (const UGameInstance* GI = GetGameInstance())
|
||||
{
|
||||
return GI->GetSubsystem<UZeusCharServerSubsystem>();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
113
Source/ZMMO/Game/UI/FrontEnd/UIUserLobbyScreen_Base.h
Normal file
113
Source/ZMMO/Game/UI/FrontEnd/UIUserLobbyScreen_Base.h
Normal file
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "UIActivatableScreen_Base.h"
|
||||
#include "ZMMOCharSummary.h"
|
||||
#include "UIUserLobbyScreen_Base.generated.h"
|
||||
|
||||
class UCommonTextBlock;
|
||||
class UBorder;
|
||||
class UPanelWidget;
|
||||
class UWidgetSwitcher;
|
||||
class UUIButton_Base;
|
||||
class UUICharCard_Base;
|
||||
class UUICharacterCreatePage_Base;
|
||||
class UZeusCharServerSubsystem;
|
||||
|
||||
/**
|
||||
* Tela Lobby (host) — Fase 1.5 do ARQUITETURA_SERVER_SELECT.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. NativeOnActivated: pega `SelectedWorldId` do FlowSubsystem, envia
|
||||
* C_CHAR_LIST_REQUEST filtrado por esse mundo
|
||||
* 2. Parse S_CHAR_LIST -> Chars[]; instancia 1 WBP_CharCard por entry
|
||||
* 3. Card "Selecionar" -> C_CHAR_SELECT(charId) -> S_CHAR_SELECT_OK ->
|
||||
* handoff UDP pro WorldServer
|
||||
* 4. Botao "Criar Personagem" -> mostra page interna `CharacterCreate`
|
||||
* 5. Botao "Voltar" -> ClearSelectedWorld + Flow.SetState(ServerSelect)
|
||||
*
|
||||
* Pages internas via UWidgetSwitcher:
|
||||
* - PageIndex 0: Lista de chars (CardContainer)
|
||||
* - PageIndex 1: Form de criar personagem (CreatePage)
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable)
|
||||
class ZMMO_API UUIUserLobbyScreen_Base : public UUIActivatableScreen_Base
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|Lobby")
|
||||
void RequestCharList();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|Lobby")
|
||||
void ShowCharSelectPage();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|Lobby")
|
||||
void ShowCharCreatePage();
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "Zeus|Lobby")
|
||||
void BackToServerSelect();
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Lobby")
|
||||
TArray<FZMMOCharSummary> Chars;
|
||||
|
||||
UFUNCTION(BlueprintImplementableEvent, Category = "Zeus|Lobby")
|
||||
void OnCharListReceived();
|
||||
|
||||
protected:
|
||||
virtual void NativeOnActivated() override;
|
||||
virtual void NativeOnDeactivated() override;
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCharRawMessage(int32 Opcode, const TArray<uint8>& Payload);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCardSelected(FString CharId);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCardDeleteRequest(FString CharId);
|
||||
|
||||
UZeusCharServerSubsystem* GetCharServer() const;
|
||||
void ParseCharList(const TArray<uint8>& Payload);
|
||||
void HandleCharSelectOk(const TArray<uint8>& Payload);
|
||||
void HandleCharSelectReject(const TArray<uint8>& Payload);
|
||||
void HandleCharCreateOk(const TArray<uint8>& Payload);
|
||||
void HandleCharCreateReject(const TArray<uint8>& Payload);
|
||||
void HandleCharDeleteAck(const TArray<uint8>& Payload);
|
||||
void HandleCharDeleteAcceptAck(const TArray<uint8>& Payload);
|
||||
void HandleCharDeleteCancelAck(const TArray<uint8>& Payload);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCardAcceptDeleteRequest(FString CharId);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCardCancelDeleteRequest(FString CharId);
|
||||
|
||||
void SendCharIdOpcode(int32 Opcode, const FString& CharId);
|
||||
void RebuildCards();
|
||||
|
||||
/** Container de cards (ScrollBox/GridPanel/UniformGridPanel). */
|
||||
UPROPERTY(meta = (BindWidget)) TObjectPtr<UPanelWidget> CardContainer;
|
||||
|
||||
/** Switcher entre Lista (0) e Criar (1). */
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UWidgetSwitcher> PageSwitcher;
|
||||
|
||||
/** Page de criacao (WBP_CharacterCreate) — opcional, pode estar embedado. */
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUICharacterCreatePage_Base> CharCreatePage;
|
||||
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_Title;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UCommonTextBlock> Text_WorldName;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUIButton_Base> Btn_Back;
|
||||
UPROPERTY(meta = (BindWidgetOptional)) TObjectPtr<UUIButton_Base> Btn_Create;
|
||||
|
||||
/** Classe do card a instanciar. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Zeus|Lobby")
|
||||
TSubclassOf<UUICharCard_Base> CharCardClass;
|
||||
|
||||
private:
|
||||
bool bSubscribed = false;
|
||||
bool bButtonsBound = false;
|
||||
|
||||
UPROPERTY(Transient)
|
||||
TArray<TObjectPtr<UUICharCard_Base>> SpawnedCards;
|
||||
};
|
||||
102
Source/ZMMO/Game/UI/FrontEnd/ZMMOCharSummary.h
Normal file
102
Source/ZMMO/Game/UI/FrontEnd/ZMMOCharSummary.h
Normal file
@@ -0,0 +1,102 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "ZMMOCharSummary.generated.h"
|
||||
|
||||
/** Stats primarios do personagem (espelha `CharStats` em char.types.ts). */
|
||||
USTRUCT(BlueprintType)
|
||||
struct FZMMOCharStats
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Str = 1;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Agi = 1;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Vit = 1;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Int = 1;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Dex = 1;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Luk = 1;
|
||||
};
|
||||
|
||||
/** Aparencia (espelha `CharAppearance` em char.types.ts). */
|
||||
USTRUCT(BlueprintType)
|
||||
struct FZMMOCharAppearance
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 Hair = 0;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 HairColor = 0;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 SkinColor = 0;
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char") int32 BodyType = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resumo de personagem recebido do CharServer via S_CHAR_LIST.
|
||||
* Espelha `CharSummary` em `Server/ZeusCharServer/src/types/char.types.ts`.
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FZMMOCharSummary
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
/** charId — BIGINT UNSIGNED no banco; armazenado como FString aqui pra preservar 64 bits. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FString CharId;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FString WorldId;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 Slot = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FString Name;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 ClassId = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 BaseLevel = 1;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 BaseExp = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 JobLevel = 1;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 JobExp = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FZMMOCharStats Stats;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 Hp = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 MaxHp = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 Sp = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 MaxSp = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int32 Money = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FString MapName;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FVector Position = FVector::ZeroVector;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
float YawDeg = 0.f;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
FZMMOCharAppearance Appearance;
|
||||
|
||||
/** Epoch ms em que o delete vai efetivar. 0 = nao agendado. */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|Char")
|
||||
int64 DeleteScheduledAtMs = 0;
|
||||
};
|
||||
45
Source/ZMMO/Game/UI/FrontEnd/ZMMOWorldEntry.h
Normal file
45
Source/ZMMO/Game/UI/FrontEnd/ZMMOWorldEntry.h
Normal file
@@ -0,0 +1,45 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "ZMMOWorldEntry.generated.h"
|
||||
|
||||
/**
|
||||
* Entrada de mundo (server) recebida do CharServer via S_WORLD_LIST.
|
||||
* Espelha `WorldRecord` em `Server/ZeusCharServer/src/types/world.types.ts`.
|
||||
*
|
||||
* State usa o wire raw (0=Offline, 1=Online, 2=Maintenance — ver
|
||||
* ZMMOWireWorldState em CharServerOpcodes.h).
|
||||
*/
|
||||
USTRUCT(BlueprintType)
|
||||
struct FZMMOWorldEntry
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
FString WorldId;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
FString WorldName;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
FString Region;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
FString Host;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
int32 Port = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
int32 Capacity = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
int32 Population = 0;
|
||||
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
int32 QueueLen = 0;
|
||||
|
||||
/** 0=Offline, 1=Online, 2=Maintenance (vide ZMMOWireWorldState). */
|
||||
UPROPERTY(BlueprintReadOnly, Category = "Zeus|World")
|
||||
uint8 State = 0;
|
||||
};
|
||||
@@ -33,16 +33,68 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
const FUIStyleButtonVariant& UUIButton_Base::ResolveVariant(const FUIStyleButton& Button) const
|
||||
FUIStyleButtonVariant UUIButton_Base::ResolveVariant(const FUIStyleButton& Button) const
|
||||
{
|
||||
switch (Variant)
|
||||
// Compõe FLAT a partir dos 3 sub-mapas do tema (Backgrounds/Strokes/Fonts).
|
||||
// Fallback inline → Defaults (constructor de FUIStyleButton popula
|
||||
// Primary/Secondary/Danger/Ghost com defaults Aurora Arcana — GC-safe).
|
||||
static const FUIStyleButton Defaults;
|
||||
FUIStyleButtonVariant V;
|
||||
|
||||
const FUIStyleButtonBackground* BG = Button.Backgrounds.Find(Variant);
|
||||
if (!BG) { BG = Defaults.Backgrounds.Find(Variant); }
|
||||
if (!BG) { BG = Defaults.Backgrounds.Find(TEXT("Primary")); }
|
||||
if (BG)
|
||||
{
|
||||
case EUIButtonVariant::Secondary: return Button.Secondary;
|
||||
case EUIButtonVariant::Danger: return Button.Danger;
|
||||
case EUIButtonVariant::Ghost: return Button.Ghost;
|
||||
case EUIButtonVariant::Primary:
|
||||
default: return Button.Primary;
|
||||
V.BgNormal = BG->BgNormal;
|
||||
V.BgHover = BG->BgHover;
|
||||
V.BgPressed = BG->BgPressed;
|
||||
V.BgDisabled = BG->BgDisabled;
|
||||
V.BackgroundTexture = BG->BackgroundTexture;
|
||||
V.BackgroundMargin = BG->BackgroundMargin;
|
||||
}
|
||||
|
||||
const FUIStyleButtonStroke* ST = Button.Strokes.Find(Variant);
|
||||
if (!ST) { ST = Defaults.Strokes.Find(Variant); }
|
||||
if (!ST) { ST = Defaults.Strokes.Find(TEXT("Primary")); }
|
||||
if (ST)
|
||||
{
|
||||
V.BorderNormal = ST->BorderNormal;
|
||||
V.BorderHover = ST->BorderHover;
|
||||
V.BorderWidth = ST->BorderWidth;
|
||||
V.CornerRadius = ST->CornerRadius;
|
||||
}
|
||||
|
||||
const FUIStyleButtonFont* F = Button.Fonts.Find(Variant);
|
||||
if (!F) { F = Defaults.Fonts.Find(Variant); }
|
||||
if (!F) { F = Defaults.Fonts.Find(TEXT("Primary")); }
|
||||
if (F)
|
||||
{
|
||||
V.TextColor = F->TextColor;
|
||||
V.TextStyle = F->TextStyle;
|
||||
}
|
||||
return V;
|
||||
}
|
||||
|
||||
TArray<FString> UUIButton_Base::GetVariantOptions() const
|
||||
{
|
||||
TArray<FString> Options;
|
||||
for (const TCHAR* N : { TEXT("Primary"), TEXT("Secondary"), TEXT("Danger"), TEXT("Ghost") })
|
||||
{
|
||||
Options.Add(N);
|
||||
}
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UIButtonVariantOptions"), false))
|
||||
{
|
||||
for (const TPair<FName, FUIStyleButtonBackground>& P : Row->Style.Button.Backgrounds) { Options.AddUnique(P.Key.ToString()); }
|
||||
for (const TPair<FName, FUIStyleButtonStroke>& P : Row->Style.Button.Strokes) { Options.AddUnique(P.Key.ToString()); }
|
||||
for (const TPair<FName, FUIStyleButtonFont>& P : Row->Style.Button.Fonts) { Options.AddUnique(P.Key.ToString()); }
|
||||
}
|
||||
}
|
||||
return Options;
|
||||
}
|
||||
|
||||
// ---- Hyper "Set Button Text" ----
|
||||
@@ -229,17 +281,18 @@ void UUIButton_Base::ApplyVisualState(EUIButtonVisual State)
|
||||
Brush.TintColor = FSlateColor(Fill);
|
||||
if (bRoundedBackground)
|
||||
{
|
||||
const float R = AS.Button.CornerRadius;
|
||||
// CornerRadius/BorderWidth agora vêm da variante composta (Stroke).
|
||||
const float R = V.CornerRadius;
|
||||
Brush.DrawAs = ESlateBrushDrawType::RoundedBox;
|
||||
Brush.OutlineSettings = FSlateBrushOutlineSettings(
|
||||
FVector4(R, R, R, R), FSlateColor(Outline), AS.Button.BorderWidth);
|
||||
FVector4(R, R, R, R), FSlateColor(Outline), V.BorderWidth);
|
||||
Brush.OutlineSettings.RoundingType = ESlateBrushRoundingType::FixedRadius;
|
||||
}
|
||||
else
|
||||
{
|
||||
Brush.DrawAs = ESlateBrushDrawType::Box;
|
||||
Brush.OutlineSettings = FSlateBrushOutlineSettings(
|
||||
FVector4(0, 0, 0, 0), FSlateColor(Outline), AS.Button.BorderWidth);
|
||||
FVector4(0, 0, 0, 0), FSlateColor(Outline), V.BorderWidth);
|
||||
}
|
||||
Background->SetBrush(Brush);
|
||||
}
|
||||
|
||||
@@ -42,8 +42,18 @@ public:
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Button")
|
||||
EUIButtonShape ButtonTypeSelection = EUIButtonShape::Rectangle;
|
||||
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Button")
|
||||
EUIButtonVariant Variant = EUIButtonVariant::Primary;
|
||||
/**
|
||||
* Variante visual do botão (dropdown vem de FUIStyle.Button.Backgrounds/
|
||||
* Strokes/Fonts no DT_UI_Styles + as 4 clássicas como fallback).
|
||||
* Data-driven via FName, sem enum fixo (padrão Hyper-style).
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Button",
|
||||
meta = (GetOptions = "GetVariantOptions"))
|
||||
FName Variant = TEXT("Primary");
|
||||
|
||||
/** Opções do dropdown de Variant (data-driven do DT_UI_Styles). */
|
||||
UFUNCTION()
|
||||
TArray<FString> GetVariantOptions() const;
|
||||
|
||||
/** Hyper "Transform Policy = To Upper": força o texto em maiúsculas. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Button")
|
||||
@@ -174,7 +184,7 @@ protected:
|
||||
meta = (DisplayName = "Apply UI Style"))
|
||||
void BP_ApplyUIStyle(const FUIStyleButton& Button, const FUIStyleButtonVariant& VariantStyle);
|
||||
|
||||
const FUIStyleButtonVariant& ResolveVariant(const FUIStyleButton& Button) const;
|
||||
FUIStyleButtonVariant ResolveVariant(const FUIStyleButton& Button) const;
|
||||
|
||||
// ---- Widgets visuais opcionais (nomes espelham o Hyper) ----
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
|
||||
216
Source/ZMMO/Game/UI/Widgets/UICheckBox_Base.cpp
Normal file
216
Source/ZMMO/Game/UI/Widgets/UICheckBox_Base.cpp
Normal file
@@ -0,0 +1,216 @@
|
||||
#include "UICheckBox_Base.h"
|
||||
|
||||
#include "ZMMOThemeSubsystem.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/DataTable.h"
|
||||
#include "Components/CheckBox.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "UICommonText_Base.h"
|
||||
#include "UI/UIStyleRow.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
// Mesmo padrão de UUISpinner_Base: runtime usa o tema ativo; design-time
|
||||
// carrega a row "Default" de DT_UI_Styles para o Designer ver igual.
|
||||
FUIStyle ResolveCheckBoxStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
{
|
||||
if (Widget)
|
||||
{
|
||||
if (const UGameInstance* GI = Widget->GetGameInstance())
|
||||
{
|
||||
if (const UZMMOThemeSubsystem* Theme = GI->GetSubsystem<UZMMOThemeSubsystem>())
|
||||
{
|
||||
return Theme->GetActiveUIStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Design-time relê o DT a cada chamada: editar o DT_UI_Styles e
|
||||
// RECOMPILAR o WBP já reflete (sem reiniciar o editor).
|
||||
FUIStyle DesignStyle;
|
||||
bool bHas = false;
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UICheckBoxDesign"), false))
|
||||
{
|
||||
DesignStyle = Row->Style;
|
||||
bHas = true;
|
||||
}
|
||||
}
|
||||
return bHas ? DesignStyle : Fallback;
|
||||
}
|
||||
}
|
||||
|
||||
TArray<FString> UUICheckBox_Base::GetLabelTextRoleOptions() const
|
||||
{
|
||||
return UUICommonText_Base::GetThemeTextRoleOptions();
|
||||
}
|
||||
|
||||
TArray<FString> UUICheckBox_Base::GetVariantOptions() const
|
||||
{
|
||||
TArray<FString> Options;
|
||||
Options.Add(TEXT("Default"));
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UICheckBoxVariantOptions"), false))
|
||||
{
|
||||
for (const TPair<FName, FUIStyleCheckBoxFill>& P : Row->Style.CheckBox.Fills) { Options.AddUnique(P.Key.ToString()); }
|
||||
for (const TPair<FName, FUIStyleCheckBoxStroke>& P : Row->Style.CheckBox.Strokes) { Options.AddUnique(P.Key.ToString()); }
|
||||
for (const TPair<FName, FUIStyleCheckBoxLayout>& P : Row->Style.CheckBox.Layouts) { Options.AddUnique(P.Key.ToString()); }
|
||||
}
|
||||
}
|
||||
return Options;
|
||||
}
|
||||
|
||||
static FUIStyleCheckBoxVariant ResolveCheckBoxVariant(const FUIStyleCheckBox& C, FName VariantName)
|
||||
{
|
||||
static const FUIStyleCheckBox Defaults;
|
||||
FUIStyleCheckBoxVariant V;
|
||||
|
||||
if (const FUIStyleCheckBoxFill* E = C.Fills.Find(VariantName)) { V.Fill = *E; }
|
||||
else if (const FUIStyleCheckBoxFill* E2 = Defaults.Fills.Find(VariantName)) { V.Fill = *E2; }
|
||||
else { V.Fill = Defaults.Fills.FindRef(TEXT("Default")); }
|
||||
|
||||
if (const FUIStyleCheckBoxStroke* E = C.Strokes.Find(VariantName)) { V.Stroke = *E; }
|
||||
else if (const FUIStyleCheckBoxStroke* E2 = Defaults.Strokes.Find(VariantName)) { V.Stroke = *E2; }
|
||||
else { V.Stroke = Defaults.Strokes.FindRef(TEXT("Default")); }
|
||||
|
||||
if (const FUIStyleCheckBoxLayout* E = C.Layouts.Find(VariantName)) { V.Layout = *E; }
|
||||
else if (const FUIStyleCheckBoxLayout* E2 = Defaults.Layouts.Find(VariantName)) { V.Layout = *E2; }
|
||||
else { V.Layout = Defaults.Layouts.FindRef(TEXT("Default")); }
|
||||
|
||||
return V;
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::RefreshUIStyle()
|
||||
{
|
||||
const FUIStyle Fallback;
|
||||
const FUIStyle AS = ResolveCheckBoxStyle(this, Fallback);
|
||||
const FUIStyleCheckBoxVariant V = ResolveCheckBoxVariant(AS.CheckBox, Variant);
|
||||
|
||||
if (Label && !LabelText.IsEmpty())
|
||||
{
|
||||
Label->SetText(LabelText);
|
||||
}
|
||||
if (UUICommonText_Base* CT = Cast<UUICommonText_Base>(Label))
|
||||
{
|
||||
if (CT->TextRole != LabelTextRole)
|
||||
{
|
||||
CT->TextRole = LabelTextRole;
|
||||
}
|
||||
CT->RefreshUIStyle();
|
||||
}
|
||||
|
||||
if (CheckBox)
|
||||
{
|
||||
FCheckBoxStyle S = CheckBox->GetWidgetStyle();
|
||||
const FVector2D Box(V.Layout.BoxSize, V.Layout.BoxSize);
|
||||
|
||||
auto Paint = [&Box](FSlateBrush& Brush, const FLinearColor& Tint)
|
||||
{
|
||||
Brush.TintColor = FSlateColor(Tint);
|
||||
Brush.ImageSize = Box;
|
||||
};
|
||||
Paint(S.UncheckedImage, V.Stroke.BorderColor);
|
||||
Paint(S.UncheckedHoveredImage, V.Fill.HoveredColor);
|
||||
Paint(S.UncheckedPressedImage, V.Fill.PressedColor);
|
||||
Paint(S.CheckedImage, V.Fill.BoxColor);
|
||||
Paint(S.CheckedHoveredImage, V.Fill.HoveredColor);
|
||||
Paint(S.CheckedPressedImage, V.Fill.PressedColor);
|
||||
Paint(S.UndeterminedImage, V.Stroke.BorderColor);
|
||||
Paint(S.UndeterminedHoveredImage, V.Fill.HoveredColor);
|
||||
Paint(S.UndeterminedPressedImage, V.Fill.PressedColor);
|
||||
|
||||
S.ForegroundColor = FSlateColor(V.Fill.CheckColor);
|
||||
S.BorderBackgroundColor = FSlateColor(V.Fill.DisabledColor);
|
||||
|
||||
CheckBox->SetWidgetStyle(S);
|
||||
}
|
||||
|
||||
BP_ApplyCheckBoxStyle(V);
|
||||
}
|
||||
|
||||
bool UUICheckBox_Base::IsChecked() const
|
||||
{
|
||||
return CheckBox ? CheckBox->IsChecked() : false;
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::SetIsChecked(bool bInChecked)
|
||||
{
|
||||
if (CheckBox)
|
||||
{
|
||||
CheckBox->SetIsChecked(bInChecked);
|
||||
}
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::NativePreConstruct()
|
||||
{
|
||||
Super::NativePreConstruct();
|
||||
RefreshUIStyle();
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::SynchronizeProperties()
|
||||
{
|
||||
Super::SynchronizeProperties();
|
||||
// Live no Designer: edita LabelText/LabelTextRole nos Detalhes e o canvas
|
||||
// reflete na hora (sem precisar recompilar o WBP).
|
||||
RefreshUIStyle();
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::NativeConstruct()
|
||||
{
|
||||
Super::NativeConstruct();
|
||||
|
||||
if (CheckBox && !bCheckBound)
|
||||
{
|
||||
CheckBox->OnCheckStateChanged.AddDynamic(this, &UUICheckBox_Base::HandleCheckStateChanged);
|
||||
bCheckBound = true;
|
||||
}
|
||||
|
||||
if (!bThemeBound)
|
||||
{
|
||||
if (const UGameInstance* GI = GetGameInstance())
|
||||
{
|
||||
if (UZMMOThemeSubsystem* Theme = GI->GetSubsystem<UZMMOThemeSubsystem>())
|
||||
{
|
||||
Theme->OnThemeChanged.AddDynamic(this, &UUICheckBox_Base::HandleThemeChanged);
|
||||
bThemeBound = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
RefreshUIStyle();
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::NativeDestruct()
|
||||
{
|
||||
if (bCheckBound && CheckBox)
|
||||
{
|
||||
CheckBox->OnCheckStateChanged.RemoveDynamic(this, &UUICheckBox_Base::HandleCheckStateChanged);
|
||||
bCheckBound = false;
|
||||
}
|
||||
if (bThemeBound)
|
||||
{
|
||||
if (const UGameInstance* GI = GetGameInstance())
|
||||
{
|
||||
if (UZMMOThemeSubsystem* Theme = GI->GetSubsystem<UZMMOThemeSubsystem>())
|
||||
{
|
||||
Theme->OnThemeChanged.RemoveDynamic(this, &UUICheckBox_Base::HandleThemeChanged);
|
||||
}
|
||||
}
|
||||
bThemeBound = false;
|
||||
}
|
||||
Super::NativeDestruct();
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::HandleThemeChanged(FName /*NewThemeId*/)
|
||||
{
|
||||
RefreshUIStyle();
|
||||
}
|
||||
|
||||
void UUICheckBox_Base::HandleCheckStateChanged(bool bIsChecked)
|
||||
{
|
||||
OnCheckStateChanged.Broadcast(bIsChecked);
|
||||
}
|
||||
98
Source/ZMMO/Game/UI/Widgets/UICheckBox_Base.h
Normal file
98
Source/ZMMO/Game/UI/Widgets/UICheckBox_Base.h
Normal file
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "CommonUserWidget.h"
|
||||
#include "UI/UIStyleTokens.h"
|
||||
#include "UICheckBox_Base.generated.h"
|
||||
|
||||
class UCheckBox;
|
||||
class UCommonTextBlock;
|
||||
|
||||
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FUICheckBoxStateChanged, bool, bIsChecked);
|
||||
|
||||
/**
|
||||
* Checkbox compartilhado do ZMMO — mesmo padrão de UUISpinner_Base /
|
||||
* UUIPanel_Base. Fundação CommonUI (UCommonUserWidget). Camada Abstract; o
|
||||
* WBP concreto (UI_CheckBox_Master) herda DIRETO desta classe C++ (UMG não
|
||||
* encadeia árvores — ARQUITETURA.md §3.3).
|
||||
*
|
||||
* Visual data-driven por FUIStyle.CheckBox (UZMMOThemeSubsystem). C++ aplica
|
||||
* o que dá no UCheckBox; cor/brush finos vão pelo hook BP_ApplyCheckBoxStyle
|
||||
* (o WBP tinge os brushes do estilo do UCheckBox).
|
||||
*/
|
||||
UCLASS(Abstract, Blueprintable)
|
||||
class ZMMO_API UUICheckBox_Base : public UCommonUserWidget
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/** Re-resolve os tokens do tema ativo e reaplica. */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI Style")
|
||||
void RefreshUIStyle();
|
||||
|
||||
UFUNCTION(BlueprintPure, Category = "CheckBox")
|
||||
bool IsChecked() const;
|
||||
|
||||
UFUNCTION(BlueprintCallable, Category = "CheckBox")
|
||||
void SetIsChecked(bool bInChecked);
|
||||
|
||||
/** Disparado quando o usuário (ou código) muda o estado da caixa. */
|
||||
UPROPERTY(BlueprintAssignable, Category = "CheckBox")
|
||||
FUICheckBoxStateChanged OnCheckStateChanged;
|
||||
|
||||
/** Rótulo ao lado da caixa. */
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "CheckBox")
|
||||
FText LabelText;
|
||||
|
||||
/**
|
||||
* Categoria tipográfica do rótulo (dropdown vem do DT_UI_Styles).
|
||||
* Exposto na raiz: dá pra trocar aqui sem selecionar o Label interno.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "CheckBox",
|
||||
meta = (GetOptions = "GetLabelTextRoleOptions"))
|
||||
FName LabelTextRole = TEXT("Label");
|
||||
|
||||
/** Opções do dropdown de LabelTextRole (data-driven do DT_UI_Styles). */
|
||||
UFUNCTION()
|
||||
TArray<FString> GetLabelTextRoleOptions() const;
|
||||
|
||||
/**
|
||||
* Variante visual do checkbox (dropdown vem de FUIStyle.CheckBox.Fills/
|
||||
* Strokes/Layouts no DT_UI_Styles). Data-driven, sem enum fixo.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "CheckBox",
|
||||
meta = (GetOptions = "GetVariantOptions"))
|
||||
FName Variant = TEXT("Default");
|
||||
|
||||
UFUNCTION()
|
||||
TArray<FString> GetVariantOptions() const;
|
||||
|
||||
protected:
|
||||
virtual void NativePreConstruct() override;
|
||||
virtual void SynchronizeProperties() override;
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
/** Hook opcional: o WBP aplica cor/brush no estilo do UCheckBox. */
|
||||
UFUNCTION(BlueprintImplementableEvent, Category = "UI Style",
|
||||
meta = (DisplayName = "Apply CheckBox Style"))
|
||||
void BP_ApplyCheckBoxStyle(const FUIStyleCheckBoxVariant& VariantStyle);
|
||||
|
||||
/** Caixa de check (nome esperado no WBP: "CheckBox"). */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UCheckBox> CheckBox;
|
||||
|
||||
/** Rótulo opcional ao lado (nome esperado no WBP: "Label"). */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
TObjectPtr<UCommonTextBlock> Label;
|
||||
|
||||
private:
|
||||
UFUNCTION()
|
||||
void HandleThemeChanged(FName NewThemeId);
|
||||
|
||||
UFUNCTION()
|
||||
void HandleCheckStateChanged(bool bIsChecked);
|
||||
|
||||
bool bThemeBound = false;
|
||||
bool bCheckBound = false;
|
||||
};
|
||||
138
Source/ZMMO/Game/UI/Widgets/UICommonText_Base.cpp
Normal file
138
Source/ZMMO/Game/UI/Widgets/UICommonText_Base.cpp
Normal file
@@ -0,0 +1,138 @@
|
||||
#include "UICommonText_Base.h"
|
||||
|
||||
#include "ZMMOThemeSubsystem.h"
|
||||
#include "Engine/GameInstance.h"
|
||||
#include "Engine/DataTable.h"
|
||||
#include "Styling/CoreStyle.h"
|
||||
#include "UI/UIStyleRow.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
const TCHAR* GStylesDT = TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles");
|
||||
|
||||
// Retorna POR VALOR (cópia fresca): nunca persiste ponteiro de FontObject
|
||||
// num static não-rastreado por GC (causa AV ao hashear a fonte no preview).
|
||||
FUIStyle ResolveTextStyle(const UWidget* Widget, const FUIStyle& Fallback)
|
||||
{
|
||||
if (Widget)
|
||||
{
|
||||
if (const UGameInstance* GI = Widget->GetGameInstance())
|
||||
{
|
||||
if (const UZMMOThemeSubsystem* Theme = GI->GetSubsystem<UZMMOThemeSubsystem>())
|
||||
{
|
||||
return Theme->GetActiveUIStyle();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Design-time relê o DT a cada chamada. Local (não-static): a fonte
|
||||
// fica viva apenas durante o RefreshUIStyle (o UObject UFont é mantido
|
||||
// pelo DataTable carregado — sem ponteiro pendurado entre frames).
|
||||
FUIStyle DesignStyle;
|
||||
bool bHas = false;
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr, GStylesDT))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UITextDesign"), false))
|
||||
{
|
||||
DesignStyle = Row->Style;
|
||||
bHas = true;
|
||||
}
|
||||
}
|
||||
return bHas ? DesignStyle : Fallback;
|
||||
}
|
||||
|
||||
// Fallback: categorias clássicas a partir de FUIStyle.Text (já configurado
|
||||
// com as FF_ fonts no DT) enquanto o mapa TextRoles não for preenchido.
|
||||
bool LegacyRole(const FUIStyle& AS, FName Role, FSlateFontInfo& OutFont,
|
||||
FLinearColor& OutColor, bool& bOutUpper)
|
||||
{
|
||||
const FUIStyleText& T = AS.Text;
|
||||
const FUIStylePalette& P = AS.Palette;
|
||||
const FString R = Role.ToString();
|
||||
bOutUpper = false;
|
||||
if (R.Equals(TEXT("Title"), ESearchCase::IgnoreCase)) { OutFont = T.TitleFont; OutColor = P.Text; return true; }
|
||||
if (R.Equals(TEXT("Section"), ESearchCase::IgnoreCase)) { OutFont = T.SectionFont; OutColor = P.Text; return true; }
|
||||
if (R.Equals(TEXT("Button"), ESearchCase::IgnoreCase)) { OutFont = T.ButtonFont; OutColor = P.Text; return true; }
|
||||
if (R.Equals(TEXT("Label"), ESearchCase::IgnoreCase)) { OutFont = T.LabelFont; OutColor = P.Text2; bOutUpper = T.bLabelUppercase; return true; }
|
||||
if (R.Equals(TEXT("Dim"), ESearchCase::IgnoreCase)) { OutFont = T.BodyFont; OutColor = P.TextDim; return true; }
|
||||
if (R.Equals(TEXT("Body"), ESearchCase::IgnoreCase)) { OutFont = T.BodyFont; OutColor = P.Text; return true; }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void UUICommonText_Base::RefreshUIStyle()
|
||||
{
|
||||
const FUIStyle Fallback;
|
||||
const FUIStyle AS = ResolveTextStyle(this, Fallback);
|
||||
|
||||
FSlateFontInfo RoleFont;
|
||||
FLinearColor RoleColor = AS.Palette.Text;
|
||||
bool bUpper = false;
|
||||
|
||||
// 1) Mapa data-driven (autor define no DT_UI_Styles).
|
||||
if (const FUITextStyle* E = AS.TextRoles.Find(TextRole))
|
||||
{
|
||||
RoleFont = E->Font;
|
||||
RoleColor = E->Color;
|
||||
bUpper = E->bUppercase;
|
||||
}
|
||||
// 2) Fallback p/ categorias clássicas de FUIStyle.Text.
|
||||
else if (!LegacyRole(AS, TextRole, RoleFont, RoleColor, bUpper))
|
||||
{
|
||||
RoleFont = AS.Text.BodyFont;
|
||||
RoleColor = AS.Palette.Text;
|
||||
}
|
||||
|
||||
// Nunca deixa sem fonte (evita o "A BASIC LATIN" gigante/quebrado).
|
||||
if (RoleFont.FontObject == nullptr && RoleFont.CompositeFont == nullptr)
|
||||
{
|
||||
RoleFont = FCoreStyle::GetDefaultFontStyle("Regular", 14);
|
||||
}
|
||||
|
||||
SetFont(RoleFont);
|
||||
SetColorAndOpacity(FSlateColor(RoleColor));
|
||||
|
||||
if (bUpper)
|
||||
{
|
||||
const FText Cur = GetText();
|
||||
if (!Cur.IsEmpty())
|
||||
{
|
||||
SetText(FText::FromString(Cur.ToString().ToUpper()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TArray<FString> UUICommonText_Base::GetTextRoleOptions() const
|
||||
{
|
||||
return GetThemeTextRoleOptions();
|
||||
}
|
||||
|
||||
TArray<FString> UUICommonText_Base::GetThemeTextRoleOptions()
|
||||
{
|
||||
TArray<FString> Options;
|
||||
// Categorias clássicas sempre disponíveis (fallback garantido).
|
||||
for (const TCHAR* N : { TEXT("Title"), TEXT("Section"), TEXT("Body"),
|
||||
TEXT("Button"), TEXT("Label"), TEXT("Dim") })
|
||||
{
|
||||
Options.Add(N);
|
||||
}
|
||||
// + chaves definidas pelo autor no DT_UI_Styles (data-driven).
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr, GStylesDT))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UITextRoleOptions"), false))
|
||||
{
|
||||
for (const TPair<FName, FUITextStyle>& Pair : Row->Style.TextRoles)
|
||||
{
|
||||
Options.AddUnique(Pair.Key.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Options;
|
||||
}
|
||||
|
||||
void UUICommonText_Base::SynchronizeProperties()
|
||||
{
|
||||
Super::SynchronizeProperties();
|
||||
RefreshUIStyle();
|
||||
}
|
||||
49
Source/ZMMO/Game/UI/Widgets/UICommonText_Base.h
Normal file
49
Source/ZMMO/Game/UI/Widgets/UICommonText_Base.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include "CoreMinimal.h"
|
||||
#include "CommonTextBlock.h"
|
||||
#include "UICommonText_Base.generated.h"
|
||||
|
||||
/**
|
||||
* Texto compartilhado do ZMMO. Subclasse de UCommonTextBlock (leaf, usado
|
||||
* direto na árvore). A categoria tipográfica NÃO é um enum fixo: é um nome
|
||||
* (FName) resolvido contra o mapa FUIStyle.TextRoles do DT_UI_Styles via
|
||||
* UZMMOThemeSubsystem — o dropdown do Details é populado a partir do DT
|
||||
* (GetOptions). Data-driven, sem struct/enum pré-definido (pedido do autor).
|
||||
*
|
||||
* Fallback: se a chave não existir no mapa, cai nas categorias clássicas de
|
||||
* FUIStyle.Text (Title/Section/Body/Button/Label/Dim) — assim já renderiza
|
||||
* com as fontes do tema (FF_Cinzel/FF_Rajdhani) mesmo antes do mapa ser
|
||||
* preenchido no DT.
|
||||
*/
|
||||
UCLASS(Blueprintable)
|
||||
class ZMMO_API UUICommonText_Base : public UCommonTextBlock
|
||||
{
|
||||
GENERATED_BODY()
|
||||
|
||||
public:
|
||||
/**
|
||||
* Nome da categoria tipográfica (chave de FUIStyle.TextRoles no
|
||||
* DT_UI_Styles). O dropdown é populado pelo DT — ver GetTextRoleOptions.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI Style",
|
||||
meta = (GetOptions = "GetTextRoleOptions"))
|
||||
FName TextRole = TEXT("Body");
|
||||
|
||||
/** Re-resolve o tema ativo e reaplica fonte/cor (chamar em troca de tema). */
|
||||
UFUNCTION(BlueprintCallable, Category = "UI Style")
|
||||
void RefreshUIStyle();
|
||||
|
||||
/** Opções do dropdown de TextRole — lidas do DT_UI_Styles (data-driven). */
|
||||
UFUNCTION()
|
||||
TArray<FString> GetTextRoleOptions() const;
|
||||
|
||||
/**
|
||||
* Mesma lista (clássicas + chaves do DT) reutilizável por outras bases
|
||||
* que expõem um TextRole na raiz (ex.: UUICheckBox_Base, UUIInput_Base).
|
||||
*/
|
||||
static TArray<FString> GetThemeTextRoleOptions();
|
||||
|
||||
protected:
|
||||
virtual void SynchronizeProperties() override;
|
||||
};
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace
|
||||
{
|
||||
const FUIStyle& ResolvePanelStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
FUIStyle ResolvePanelStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
{
|
||||
if (Widget)
|
||||
{
|
||||
@@ -24,22 +24,21 @@ namespace
|
||||
// Design-time (sem GameInstance/subsystem): carrega o DT_UI_Styles
|
||||
// direto e usa a row "Default", para o Designer pintar/mostrar os
|
||||
// brushes igual ao runtime (caso contrário FUIStyle vem vazio).
|
||||
static FUIStyle DesignStyle;
|
||||
static bool bDesignLoaded = false;
|
||||
if (!bDesignLoaded)
|
||||
// Design-time relê o DT a cada chamada: editar o DT_UI_Styles e
|
||||
// RECOMPILAR o WBP já reflete (sem reiniciar o editor).
|
||||
FUIStyle DesignStyle;
|
||||
bool bHas = false;
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UIPanelDesign"), false))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UIPanelDesign"), false))
|
||||
{
|
||||
DesignStyle = Row->Style;
|
||||
bDesignLoaded = true;
|
||||
}
|
||||
DesignStyle = Row->Style;
|
||||
bHas = true;
|
||||
}
|
||||
}
|
||||
return bDesignLoaded ? DesignStyle : Fallback;
|
||||
return bHas ? DesignStyle : Fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +49,7 @@ void UUIPanel_Base::RefreshUIStyle()
|
||||
return;
|
||||
}
|
||||
const FUIStyle Fallback;
|
||||
const FUIStyle& AS = ResolvePanelStyle(this, Fallback);
|
||||
const FUIStyle AS = ResolvePanelStyle(this, Fallback);
|
||||
const FUIStylePanel& P = AS.Panel;
|
||||
|
||||
// ---- Modo TEXTURA (padrão Hyper): brush por EUIPanelTexture ----
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace
|
||||
{
|
||||
// Mesmo padrão de UUIPanel_Base: runtime usa o tema ativo; design-time
|
||||
// carrega a row "Default" de DT_UI_Styles para o Designer ver igual.
|
||||
const FUIStyle& ResolveSpinnerStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
FUIStyle ResolveSpinnerStyle(const UUserWidget* Widget, const FUIStyle& Fallback)
|
||||
{
|
||||
if (Widget)
|
||||
{
|
||||
@@ -22,40 +22,71 @@ namespace
|
||||
}
|
||||
}
|
||||
}
|
||||
static FUIStyle DesignStyle;
|
||||
static bool bDesignLoaded = false;
|
||||
if (!bDesignLoaded)
|
||||
// Design-time relê o DT a cada chamada: editar o DT_UI_Styles e
|
||||
// RECOMPILAR o WBP já reflete (sem reiniciar o editor).
|
||||
FUIStyle DesignStyle;
|
||||
bool bHas = false;
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UISpinnerDesign"), false))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UISpinnerDesign"), false))
|
||||
{
|
||||
DesignStyle = Row->Style;
|
||||
bDesignLoaded = true;
|
||||
}
|
||||
DesignStyle = Row->Style;
|
||||
bHas = true;
|
||||
}
|
||||
}
|
||||
return bDesignLoaded ? DesignStyle : Fallback;
|
||||
return bHas ? DesignStyle : Fallback;
|
||||
}
|
||||
}
|
||||
|
||||
static FUIStyleSpinnerVariant ResolveSpinnerVariant(const FUIStyleSpinner& Sp, FName VariantName)
|
||||
{
|
||||
static const FUIStyleSpinner Defaults;
|
||||
FUIStyleSpinnerVariant V;
|
||||
|
||||
if (const FUIStyleSpinnerColors* E = Sp.Colors.Find(VariantName)) { V.Colors = *E; }
|
||||
else if (const FUIStyleSpinnerColors* E2 = Defaults.Colors.Find(VariantName)) { V.Colors = *E2; }
|
||||
else { V.Colors = Defaults.Colors.FindRef(TEXT("Default")); }
|
||||
|
||||
if (const FUIStyleSpinnerLayout* E = Sp.Layouts.Find(VariantName)) { V.Layout = *E; }
|
||||
else if (const FUIStyleSpinnerLayout* E2 = Defaults.Layouts.Find(VariantName)) { V.Layout = *E2; }
|
||||
else { V.Layout = Defaults.Layouts.FindRef(TEXT("Default")); }
|
||||
|
||||
return V;
|
||||
}
|
||||
|
||||
TArray<FString> UUISpinner_Base::GetVariantOptions() const
|
||||
{
|
||||
TArray<FString> Options;
|
||||
Options.Add(TEXT("Default"));
|
||||
if (const UDataTable* DT = LoadObject<UDataTable>(nullptr,
|
||||
TEXT("/Game/ZMMO/Data/UI/DT_UI_Styles.DT_UI_Styles")))
|
||||
{
|
||||
if (const FUIStyleRow* Row = DT->FindRow<FUIStyleRow>(
|
||||
FName(TEXT("Default")), TEXT("UISpinnerVariantOptions"), false))
|
||||
{
|
||||
for (const TPair<FName, FUIStyleSpinnerColors>& P : Row->Style.Spinner.Colors) { Options.AddUnique(P.Key.ToString()); }
|
||||
for (const TPair<FName, FUIStyleSpinnerLayout>& P : Row->Style.Spinner.Layouts) { Options.AddUnique(P.Key.ToString()); }
|
||||
}
|
||||
}
|
||||
return Options;
|
||||
}
|
||||
|
||||
void UUISpinner_Base::RefreshUIStyle()
|
||||
{
|
||||
const FUIStyle Fallback;
|
||||
const FUIStyle& AS = ResolveSpinnerStyle(this, Fallback);
|
||||
const FUIStyleSpinner& S = AS.Spinner;
|
||||
const FUIStyle AS = ResolveSpinnerStyle(this, Fallback);
|
||||
const FUIStyleSpinnerVariant V = ResolveSpinnerVariant(AS.Spinner, Variant);
|
||||
|
||||
if (Throbber)
|
||||
{
|
||||
Throbber->SetNumberOfPieces(FMath::Max(1, S.NumberOfPieces));
|
||||
Throbber->SetPeriod(FMath::Max(0.05f, S.Period));
|
||||
Throbber->SetRadius(S.Radius);
|
||||
Throbber->SetNumberOfPieces(FMath::Max(1, V.Layout.NumberOfPieces));
|
||||
Throbber->SetPeriod(FMath::Max(0.05f, V.Layout.Period));
|
||||
Throbber->SetRadius(V.Layout.Radius);
|
||||
}
|
||||
|
||||
// Cor/brush: o WBP aplica no throbber (mesma divisão de UUIPanel_Base).
|
||||
BP_ApplySpinnerStyle(S);
|
||||
BP_ApplySpinnerStyle(V);
|
||||
}
|
||||
|
||||
void UUISpinner_Base::NativePreConstruct()
|
||||
|
||||
@@ -27,15 +27,26 @@ public:
|
||||
UFUNCTION(BlueprintCallable, Category = "UI Style")
|
||||
void RefreshUIStyle();
|
||||
|
||||
/**
|
||||
* Variante visual do spinner (dropdown vem de FUIStyle.Spinner.Colors/
|
||||
* Layouts no DT_UI_Styles). Data-driven, sem enum fixo.
|
||||
*/
|
||||
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spinner",
|
||||
meta = (GetOptions = "GetVariantOptions"))
|
||||
FName Variant = TEXT("Default");
|
||||
|
||||
UFUNCTION()
|
||||
TArray<FString> GetVariantOptions() const;
|
||||
|
||||
protected:
|
||||
virtual void NativePreConstruct() override;
|
||||
virtual void NativeConstruct() override;
|
||||
virtual void NativeDestruct() override;
|
||||
|
||||
/** Hook opcional: o WBP aplica cor/brush do spinner (FUIStyleSpinner). */
|
||||
/** Hook opcional: o WBP aplica cor/brush do spinner (variante composta). */
|
||||
UFUNCTION(BlueprintImplementableEvent, Category = "UI Style",
|
||||
meta = (DisplayName = "Apply Spinner Style"))
|
||||
void BP_ApplySpinnerStyle(const FUIStyleSpinner& Spinner);
|
||||
void BP_ApplySpinnerStyle(const FUIStyleSpinnerVariant& VariantStyle);
|
||||
|
||||
/** Throbber circular (nome esperado no WBP: "Throbber"). */
|
||||
UPROPERTY(meta = (BindWidgetOptional))
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"Enabled": true,
|
||||
"SupportedTargetPlatforms": [
|
||||
"Win64"
|
||||
]
|
||||
],
|
||||
"MarketplaceURL": "com.epicgames.launcher://ue/marketplace/product/362651520df94e4fa65492dbcba44ae2"
|
||||
},
|
||||
{
|
||||
"Name": "ZeusNetwork",
|
||||
|
||||
Reference in New Issue
Block a user