Old Knight and the Witch — Guia Unity
Parte 2 — Guia de Implementação Unity
Este guia constrói o Old Knight and the Witch em Unity 6, do projeto vazio até a build de Android. O combate do Cavaleiro é o motor do Guerreiro da Masmorra, que já tem um guia completo em GuerreiroDaMasmorra.md (Partes 2 a 12). Este guia não reescreve aquele motor. A Fase 1 manda copiar as fases certas de lá, com uma lista exata do que trocar. As fases seguintes escrevem completo só o que é novo:
- as reações do app (abaixar e a lateral com lado);
- o palco 3D com sprites iluminados;
- a dupla com troca;
- a Bruxa com deck e mana;
- o acampamento da dupla;
- a árvore de Souls e as missões diárias do app.
Como usar este guia
O formato é o do guia do Guerreiro (ver "Como usar este guia" e "Duas operações que se repetem no guia inteiro" em GuerreiroDaMasmorra.md, Parte 2):
- Etapas pequenas, cada uma com um 🧪 Teste rápido.
- Passos no Editor clique a clique, no formato Menu → Submenu → Item.
- Um ✅ Checkpoint no fim de cada fase.
Há dois tipos de listagem:
- Script novo: completo. Crie o arquivo, apague o que a Unity gerou e cole o script inteiro.
- Mudança num script que veio do guia do Guerreiro: a etapa diz o arquivo, o trecho a procurar e o que entra no lugar. Quando a mudança mexe em quase tudo, a listagem é o arquivo inteiro e diz isso.
Convenções
- Nomes no projeto em inglês, comentários em português. Cada classe começa com
// POR QUE:e// ESTRATÉGIA:, e cada método tem um comentário curto acima dele, como no guia do Guerreiro. - Namespaces: os do Guerreiro com outro prefixo.
GuerreiroDaMasmorra.DataviraOldKnight.Data, e o mesmo vale paraCombat,Battle,Meta,Services,EditorToolseTests. - Menus do Editor:
OKW/...no lugar deGDM/.... - ids: os ids de monstros do jogo original continuam iguais (
pawn,kingslime...). Os heróis sãoknightewitch. - Enums novos vão no fim. A Unity grava enums como número no asset; um valor inserido no meio mudaria o significado dos assets que já existem.
- Textos na tela sem emoji, pela mesma razão do guia do Guerreiro: a fonte do TextMeshPro não tem emojis.
Arquitetura em uma página
Dados (ScriptableObjects) Regras (C# puro) Cena Battle (MonoBehaviours)
───────────────────────── ──────────────── ────────────────────────────
GameBalance, GameDatabase WaveFactory, Initiative (+ dupla) BattleController (ondas, rodadas, troca forçada)
MonsterDefinition (+golpes) HitResolver (+ abaixar, lado) ├─ WarriorTurn ── CommandMenu (+ Trocar)
HeroDefinition (+velocidade, vagas) PartyRun (quem está em campo) ├─ WitchTurn ──── WitchHandView, CardView
CardDefinition (cartas da Bruxa) WitchDeck, WitchMath (mão, mana) ├─ PartySwap (troca encenada)
BiomeDefinition (corredor 3D) SkillTree, QuestRules ├─ MonsterTurn, BoomResolver
└─ BattleStage (3D) ── CombatantView (sprite iluminado)
└─ CorridorBuilder (estrada, paredes, tochas)
Cena Camp: CampScreen (+ aba da dupla), DeckScreen, CardRewardPanel, SkillTreeScreen, QuestScreen ── GameSession ── PlayerPrefs
- A regra de ouro do guia do Guerreiro continua: fórmulas, sorteios e estado ficam em classes C# comuns, e os MonoBehaviours só animam, mostram e coletam o toque. A dupla (
PartyRun) e o deck (WitchDeck) também são C# puro e têm testes na Fase 7. - A dupla entra por um ponto só. O
BattleContextdo Guerreiro tem um campoHero, e quase todo o combate usactx.Hero. AquiHeropassa a ser "quem está em campo agora" (Party.Active). Assim o turno do monstro, o QTE e o HUD continuam funcionando sem saber que existe uma dupla. - A Bruxa é outro
IHeroTurnTaker, ao lado doWarriorTurn. O controlador escolhe o turno pela classe de quem está em campo. - O palco 3D troca só a aparência.
BattleStageeCombatantViewmantêm os mesmos métodos públicos do guia do Guerreiro (HeroApproach,Float,MonsterAt...), mas passam a trabalhar no chão 3D (eixo X para os lados, Z para o fundo do corredor). Quem chama não muda.
Mapa de sistemas
| Namespace | Script | Responsabilidade | Fase |
|---|---|---|---|
| todos | os scripts das Fases 1 a 7 de GuerreiroDaMasmorra.md | O motor do Guerreiro, renomeado | 1 |
Data/EditorTools/Combat/Battle | Enums, GameDataSeeder, HitResolver, GestureInput, QteController (mudanças) | Elenco do app, abaixar e lateral com lado | 1 |
Data/Battle | BiomeDefinition, CorridorBuilder, TorchFlicker, SpriteBillboard | Corredor 3D e luz | 2 |
Battle | CombatantView, BattleStage (substituem os do Guerreiro) | Personagens em sprites iluminados no chão 3D | 2 |
Combat | PartyRun, HeroRun/HeroLoadout/Initiative (mudanças) | A dupla e a fila por velocidade | 3 |
Battle | BattleContext, BattleController (substituem), PartySwap, PartyHud | Troca, troca forçada, derrota da dupla | 3 |
Data/Combat | CardDefinition, WitchDeck, WitchMath | Cartas, mão e mana | 4 |
Battle | CardView, WitchHandView, WitchTurn | O turno da Bruxa | 4 |
Meta | PartyRules, SaveData/GameSession (mudanças), CardRewardPanel, DeckScreen | Acampamento da dupla e deck | 5 |
Meta | SkillTree, SkillTreeScreen, QuestRules, QuestScreen | Árvore de Souls e missões diárias | 6 |
Tests | PartyTests, WitchDeckTests | Testes da dupla e do deck | 7 |
Fases
| Fase | Parte | Conteúdo | Etapas |
|---|---|---|---|
| 0 | 3 | Setup do projeto (3D URP, retrato) | 0A projeto · 0B pastas e cenas |
| 1 | 4 | O motor do Guerreiro | 1A copiar e renomear · 1B elenco do app · 1C abaixar e lateral com lado |
| 2 | 5 | Palco HD-2D | 2A sprite iluminado · 2B corredor 3D · 2C personagens no 3D · 2D câmera e luz |
| 3 | 6 | A dupla | 3A estado da dupla · 3B fila · 3C controlador · 3D comando Trocar · 3E painel da dupla |
| 4 | 7 | A Bruxa | 4A cartas · 4B deck e mana · 4C mão na tela · 4D turno da Bruxa |
| 5 | 8 | Acampamento da dupla | 5A save e regras · 5B telas |
| 6 | 9 | Extras do app | 6A árvore de Souls · 6B missões diárias |
| 7 | 10 | Testes automáticos | — |
| 8 | 11 | Build, anúncios e próximos passos | — |
Parte 3 — Implementação Unity: Fase 0 (Setup do Projeto)
Objetivo desta fase: um projeto Unity 6 3D (URP) vazio, em retrato, versionado no Git, com as pastas e as duas cenas do jogo.
Conceitos novos (os do Guerreiro valem; ver GuerreiroDaMasmorra.md, Fase 0):
- Template Universal 3D: projeto com o URP (Universal Render Pipeline) em modo 3D: câmera em perspectiva, luzes em tempo real, sombras e névoa. É o que dá o visual HD-2D: o cenário é 3D de verdade e os personagens são sprites 2D em pé dentro dele.
- Sprite em cena 3D: um
SpriteRendererfunciona numa cena 3D como uma placa fina. Com o material certo (Fase 2), a placa recebe a luz das tochas e projeta sombra no chão.
Etapa 0A — Projeto e repositório
- Instale o Unity 6 LTS com Android Build Support (marque OpenJDK e Android SDK & NDK Tools) e Web Build Support, como na Etapa 0A do Guerreiro.
- Unity Hub → Projects → New project → template Universal 3D → nome
OldKnightAndTheWitch→ Create project. - Repositório,
.gitignore, Git LFS, Visible Meta Files e Force Text: igual à Etapa 0B do Guerreiro. - Window → Package Manager → Unity Registry, instale ou confira:
- Input System (a Unity pede para reiniciar: aceite);
- Test Framework;
- Shader Graph (já vem com o URP). O TextMeshPro já vem no pacote uGUI; na primeira vez que criar um texto, aceite Import TMP Essentials.
- Edit → Project Settings → Player → Other Settings → Active Input Handling =
Input System Package (New).
Etapa 0B — Pastas, cenas e tela retrato
-
Crie em
Assets/a mesma árvore do Guerreiro, com quatro pastas a mais:Assets/_Project/Art/Sprites/Textures/ (chão, margens, paredes)Materials/Shaders/Fonts/Data/Monsters/Heroes/Gems/Anims/Cards/Biomes/Prefabs/Scenes/Scripts/Data/Combat/Battle/Meta/Services/Editor/Tests/ -
Mova
Assets/Scenes/SampleScenepara_Project/Scenes/e renomeie paraBattle. CrieCamp: File → New Scene → Basic (URP) → Create, salve em_Project/Scenes/Camp. -
Na Game view, crie e selecione a resolução
Retrato 1080x1920(Fixed Resolution). Em Project Settings → Player → Resolution and Presentation, Default Orientation =Portrait. -
Qualidade para celular: Project Settings → Quality, selecione o nível Mobile e marque-o como padrão para Android (a seta verde na coluna do Android). No asset do URP desse nível (
Assets/Settings/Mobile_RPAsset), confira Lighting → Additional Lights =Per Pixele Per Object Limit =4: as tochas da Fase 2 precisam de luzes adicionais.
🧪 Teste rápido
Abra Battle e aperte ▶. A Game view mostra o céu padrão do URP num retângulo em pé, sem erros no Console.
✅ Checkpoint da Fase 0
- Projeto Universal 3D com Input System, Test Framework e Shader Graph.
- Cenas
CampeBattleem_Project/Scenes/; Game view em retrato. - Commit:
chore: estrutura do projeto.
Próxima fase: Fase 1 — O motor do Guerreiro.
Parte 4 — Implementação Unity: Fase 1 (O motor do Guerreiro)
Objetivo desta fase: o combate do Guerreiro da Masmorra rodando neste projeto, com o elenco do app e as duas reações que o app acrescentou (abaixar e lateral com lado). No fim, uma masmorra inteira se joga com o Cavaleiro sozinho, ainda no palco 2D do guia do Guerreiro.
Conceitos novos:
- Renomear um namespace em massa: o editor de código troca um texto em todos os arquivos de uma pasta (Edit → Find and Replace → Replace in Files no Visual Studio; Ctrl+Shift+H no VS Code e no Rider).
- Palco provisório: nesta fase a batalha usa o palco 2D do guia do Guerreiro (câmera ortográfica, sprites chapados). Um projeto URP 3D desenha sprites 2D sem problema. A Fase 2 troca esse palco pelo 3D.
Etapa 1A — Copiar e renomear
Siga GuerreiroDaMasmorra.md e crie, na mesma ordem, todos os scripts das fases abaixo. Monte as cenas como aquele guia manda.
| Fase do guia do Guerreiro | O que fazer aqui |
|---|---|
| 1 — Dados | Tudo. O GameDataSeeder muda na Etapa 1B |
| 2 — Regras de combate | Tudo, e o RulesSmokeTest também |
| 3 — A batalha andando sozinha | Tudo. O BattleStage e o CombatantView são provisórios (a Fase 2 os substitui). No BattleController, deixe o campo Mage Turn vazio |
| 4 — Reações (QTE) | Tudo. A Etapa 1C muda o GestureInput e o QteController |
| 5 — Especiais e pedrada | Tudo |
| 6 — Fim de masmorra | Tudo |
| 7 — Acampamento, mercado e save | Tudo. A Fase 5 deste guia acrescenta a dupla |
| 8 — Testes automáticos | Tudo (a Fase 7 deste guia acrescenta os testes novos) |
| 9 — Build, anúncios e nuvem | Ainda não. Os anúncios entram na Fase 8 deste guia; a nuvem fica fora do MVP |
| 10 — Mago, heróis e duelos | Não. O Mago e o mapa-múndi não existem neste jogo; a Bruxa é a Fase 4 |
Depois de criar os scripts, troque os nomes do Guerreiro pelos deste jogo com Replace in Files, só dentro de Assets/_Project:
| Procurar | Trocar por | Por quê |
|---|---|---|
GuerreiroDaMasmorra. | OldKnight. | namespaces e using |
namespace GuerreiroDaMasmorra | namespace OldKnight | os poucos arquivos sem ponto depois do nome |
"GDM/ | "OKW/ | menus do Editor e de criação de assets |
gdm.save.v1 | okw.save.v1 | chave do save no PlayerPrefs (o jogo não lê o save do Guerreiro) |
GDM: | OKW: | mensagens do Console |
Os três Assembly Definitions da Fase 8 do Guerreiro também mudam de nome: OldKnight, OldKnight.Editor e OldKnight.Tests, com as referências apontando para OldKnight.
🧪 Teste rápido
O Console compila sem erros. OKW → Criar ou atualizar dados iniciais cria os dados, e a cena Battle aberta direto joga uma masmorra (o BattleTestStarter do guia do Guerreiro).
Etapa 1B — Elenco do app, os dois heróis e a velocidade
Enums.cs: acrescente Witch no fim do HeroClass e três reações no fim do ReactionType:
// POR QUE: as reações da Parte 1 §5. As três últimas vieram do app:
// abaixar (golpe alto) e a lateral que exige um lado.
public enum ReactionType
{
Parry,
Jump,
Side,
Duck,
SideLeft,
SideRight,
}
// POR QUE: o sistema de combate do herói. Mage fica no enum (o guia do
// Guerreiro o usa) mas não é jogável neste jogo.
public enum HeroClass
{
Warrior,
Mage,
Witch,
}
HeroDefinition.cs: arquivo inteiro. Entram a velocidade e as vagas de cada personagem (Parte 1 §3 e §4.2):
// Caminho: Assets/_Project/Scripts/Data/HeroDefinition.cs
using UnityEngine;
namespace OldKnight.Data
{
// POR QUE: um personagem jogável (Parte 1 §3): atributos iniciais,
// sistema de combate e o ritmo dele na fila de iniciativa.
// ESTRATÉGIA: só o ponto de partida; o que o jogador comprou fica no
// save. `speed` e `turnsPerRound` fazem o Cavaleiro ser lento e a Bruxa
// rápida sem nenhuma regra especial no controlador: a fila só lê estes
// números de quem está em campo.
[CreateAssetMenu(fileName = "Hero", menuName = "OKW/Hero")]
public class HeroDefinition : ScriptableObject
{
public string id;
public string displayName;
public HeroClass heroClass;
public int maxHp;
public int attack;
public int defense;
[Tooltip("Só o Cavaleiro ganha Fúria com o ataque simples.")]
public bool furyOnAttack;
[Tooltip("Mantido do guia do Guerreiro; não é usado (sem mapa-múndi).")]
public int recruitCity = -1;
[Header("Ritmo na fila (Parte 1 §4.2)")]
[Tooltip("Velocidade-base da vaga do herói quando este personagem está em campo.")]
public int speed = 10;
[Tooltip("Quantas vagas por rodada.")]
public int turnsPerRound = 1;
[Tooltip("Quanto cada vaga extra perde na rolagem.")]
public int extraTurnPenalty = 4;
[Header("Só a Bruxa (Parte 1 §7.1)")]
public int handSize = 4;
public int manaPerTurn = 2;
public int maxMana = 4;
}
}
GameDataSeeder.cs, quatro mudanças:
-
A expressão dos hits aceita
A(abaixar) eS</S>(lateral com lado). Troque a linha doHitToken:// P = parry, J = salto, S = lateral, S< / S> = lateral com lado, A = abaixar.private static readonly Regex HitToken = new Regex(@"^(P|J|S<|S>|S|A)([0-9]*\.?[0-9]*)([ad]?)$");E, dentro do método
M, troque a atribuição detype:type = match.Groups[1].Value switch{"P" => ReactionType.Parry,"J" => ReactionType.Jump,"A" => ReactionType.Duck,"S<" => ReactionType.SideLeft,"S>" => ReactionType.SideRight,_ => ReactionType.Side,}, -
Os nomes dos comuns e do chefe que o app renomeou (Parte 1 §8). Só o texto entre aspas do nome muda:
id Nome do guia do Guerreiro Nome aqui pawnServo Cavaleiro Negro archerArqueiro Arqueiro Espectral lancerLanceiro Lanceiro Sepulcral monkMonge Cultista gnomeGnomo Arruaceiro Servo Deformado thiefLadrão Espectro Ladino snakeCobra Venenosa Serpente Podre skullCaveira Amaldiçoada Lich Amaldiçoado shamanXamã Selvagem Necromante pandaPanda Guerreiro Ogro Carrasco -
O chefe novo, depois do
panda(a ordem dos chefes decide o chefe fixo de cada masmorra):// Do app: o Arqueiro Ancestral não apara; só esquivas com lado e um salto no fim.Monster(true, "archerlord", "Arqueiro Ancestral", 175, 19, 5, 3, 8, "dist",M("Tiro Cruzado", "S<.85 S>.85 J.9"), M("Cerco de Flechas", "S>.8 A.8 S<.8 J.9"),M("Salva do Ancestral", "A.8 S<.8 S>.8a J.95", 6)); -
Os heróis. Troque o bloco
// §3 — heróis.inteiro (a função localHeroe as cinco chamadas) por este:// Parte 1 §3 — a dupla. Os números são a proposta do GDD.var heroes = new List<HeroDefinition>();HeroDefinition Hero(string id, string name, HeroClass cls, int hp, int atk, int def, bool furyOnAttack,int speed, int turns){var h = LoadOrCreate<HeroDefinition>($"{Root}/Heroes/{id}.asset");h.id = id;h.displayName = name;h.heroClass = cls;h.maxHp = hp;h.attack = atk;h.defense = def;h.furyOnAttack = furyOnAttack;h.speed = speed;h.turnsPerRound = turns;EditorUtility.SetDirty(h);heroes.Add(h);return h;}Hero("knight", "Velho Cavaleiro", HeroClass.Warrior, 70, 14, 4, true, 6, 1);Hero("witch", "Bruxa", HeroClass.Witch, 45, 8, 2, false, 13, 2); -
No
SaveData(Fase 7 do Guerreiro), troque o padrão deactiveHerode"warrior"para"knight". OMetaRules.NewGamedo Guerreiro cria a progressão de todos os heróis da lista, e os dois nascem liberados (recruitCityé-1). -
O herói
warriordeixou de existir, mas os testes do Guerreiro conferem números dele (HP 60, ATK 10). Para eles continuarem valendo, os testes passam a usar um Guerreiro criado só na memória. NoTestData, acrescente:// O Guerreiro do guia original, criado só na memória: os testes// herdados conferem os números dele (HP 60, ATK 10, DEF 3).public static HeroDefinition WarriorDef(){var h = ScriptableObject.CreateInstance<HeroDefinition>();h.id = "warrior";h.heroClass = HeroClass.Warrior;h.maxHp = 60;h.attack = 10;h.defense = 3;h.furyOnAttack = true;return h;}(com
using UnityEngine;no topo, se ainda não houver). Depois troqueDb.Hero("warrior")porWarriorDef()nos testes, e"warrior"por"knight"nas chamadasMetaRules.Progress(save, "warrior"), noRulesSmokeTeste no campo Hero Id doBattleTestStarter.
O Guerreiro tinha
heroSpeed = 10noGameBalance. Ele continua lá, mas só como reserva: a fila da Fase 3 lê a velocidade doHeroDefinition.
🧪 Teste rápido
OKW → Criar ou atualizar dados iniciais. O Console mostra OKW: 19 comuns, 8 chefes, 2 heróis e 4 gemas prontos. Abra Data/Monsters/boss_archerlord: Cerco de Flechas tem 4 hits (SideRight, Duck, SideLeft, Jump).
Etapa 1C — Abaixar e lateral com lado
HitResolver.cs: as reações novas se resolvem como a lateral (esquivou: zero dano e nenhuma Fúria; errou: dano cheio). Troque o primeiro if de Resolve:
// Lateral, lateral com lado e abaixar: só evitam o dano (Parte 1 §5).
if (type is ReactionType.Side or ReactionType.SideLeft or ReactionType.SideRight or ReactionType.Duck)
{
damage = result == QteResult.Dodge ? 0 : CombatMath.FullHit(attack, rng);
}
GestureInput.cs: arquivo inteiro. Entram o deslize para baixo e o lado do deslize:
// Caminho: Assets/_Project/Scripts/Battle/GestureInput.cs
using System;
using UnityEngine;
using UnityEngine.InputSystem;
namespace OldKnight.Battle
{
// POR QUE: traduz toque, mouse e teclado nos gestos do jogo (Parte 1 §5):
// tocar, arrastar para cima, arrastar para baixo e deslizar para um lado.
// ESTRATÉGIA: o toque é anunciado no instante em que o dedo encosta (o
// tempo do parry é medido aí); o deslize, quando o dedo anda mais que o
// limite numa direção dominante, uma vez por toque. O deslize lateral
// diz o lado: -1 esquerda, +1 direita. Teclado: Espaço ou Enter = toque,
// ↑ = salto, ↓ = abaixar, ← → = lateral.
public class GestureInput : MonoBehaviour
{
[SerializeField] private float _swipeInches = 0.2f;
public event Action Tapped;
public event Action SwipedUp;
public event Action SwipedDown;
public event Action<int> SwipedSide;
private Vector2 _start;
private bool _tracking;
// Lê os dispositivos a cada quadro.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard != null)
{
if (keyboard.spaceKey.wasPressedThisFrame || keyboard.enterKey.wasPressedThisFrame) Tapped?.Invoke();
if (keyboard.upArrowKey.wasPressedThisFrame) SwipedUp?.Invoke();
if (keyboard.downArrowKey.wasPressedThisFrame) SwipedDown?.Invoke();
if (keyboard.leftArrowKey.wasPressedThisFrame) SwipedSide?.Invoke(-1);
if (keyboard.rightArrowKey.wasPressedThisFrame) SwipedSide?.Invoke(1);
}
var pointer = Pointer.current;
if (pointer == null) return;
Vector2 position = pointer.position.ReadValue();
if (pointer.press.wasPressedThisFrame)
{
_start = position;
_tracking = true;
Tapped?.Invoke();
}
if (_tracking && pointer.press.isPressed)
{
Vector2 delta = position - _start;
float limit = _swipeInches * (Screen.dpi > 0 ? Screen.dpi : 160f);
bool vertical = Mathf.Abs(delta.y) > Mathf.Abs(delta.x);
if (vertical && delta.y > limit)
{
_tracking = false;
SwipedUp?.Invoke();
}
else if (vertical && delta.y < -limit)
{
_tracking = false;
SwipedDown?.Invoke();
}
else if (!vertical && Mathf.Abs(delta.x) > limit)
{
_tracking = false;
SwipedSide?.Invoke(delta.x > 0 ? 1 : -1);
}
}
if (pointer.press.wasReleasedThisFrame) _tracking = false;
}
}
}
QteController.cs: o SwipedSide agora passa o lado, e o abaixar é novo. Troque o método React inteiro e as três funções do fim do arquivo (ColorOf, IconOf, TipOf):
// Abre o círculo para um golpe de monstro e devolve a reação.
public async Awaitable<QteResult> React(ReactionType type, float seconds, QteCurve curve, string tag)
{
var result = QteResult.Miss;
float start = Time.time;
void OnTap()
{
if (type != ReactionType.Parry || result != QteResult.Miss) return;
result = QteTiming.IsPerfect(Time.time - start, seconds, Balance) ? QteResult.Perfect : QteResult.Block;
_tip.text = result == QteResult.Perfect ? "PERFEITO!" : "BLOQUEADO";
}
void OnUp()
{
if (type != ReactionType.Jump || result != QteResult.Miss) return;
result = QteResult.Dodge;
_tip.text = "ESQUIVA! ATAQUE NO AR!";
}
void OnDown()
{
if (type != ReactionType.Duck || result != QteResult.Miss) return;
result = QteResult.Dodge;
_tip.text = "ABAIXOU!";
}
void OnSide(int side)
{
if (result != QteResult.Miss) return;
bool any = type == ReactionType.Side;
bool left = type == ReactionType.SideLeft && side < 0;
bool right = type == ReactionType.SideRight && side > 0;
if (!any && !left && !right) return; // lado errado: o golpe acerta (Parte 1 §5)
result = QteResult.Dodge;
_tip.text = "ESQUIVA LATERAL!";
}
_input.Tapped += OnTap;
_input.SwipedUp += OnUp;
_input.SwipedDown += OnDown;
_input.SwipedSide += OnSide;
try
{
Open(ColorOf(type), IconOf(type), Prefix(tag) + TipOf(type));
await RunRing(seconds, curve, () => result == QteResult.Miss);
}
finally
{
_input.Tapped -= OnTap;
_input.SwipedUp -= OnUp;
_input.SwipedDown -= OnDown;
_input.SwipedSide -= OnSide;
_root.SetActive(false);
}
return result;
}
private Color ColorOf(ReactionType type) => type switch
{
ReactionType.Jump => _jumpColor,
ReactionType.Duck => _jumpColor,
ReactionType.Side or ReactionType.SideLeft or ReactionType.SideRight => _sideColor,
_ => _parryColor,
};
private static string IconOf(ReactionType type) => type switch
{
ReactionType.Jump => "PULE",
ReactionType.Duck => "ABAIXE",
ReactionType.SideLeft => "<< DESVIE",
ReactionType.SideRight => "DESVIE >>",
ReactionType.Side => "DESVIE",
_ => "APARE",
};
private static string TipOf(ReactionType type) => type switch
{
ReactionType.Jump => "ARRASTE PARA CIMA!",
ReactionType.Duck => "ARRASTE PARA BAIXO!",
ReactionType.SideLeft => "DESLIZE PARA A ESQUERDA!",
ReactionType.SideRight => "DESLIZE PARA A DIREITA!",
ReactionType.Side => "DESLIZE PARA O LADO!",
_ => "TOQUE QUANDO O CÍRCULO FECHAR!",
};
Se algum outro script do guia do Guerreiro assina _input.SwipedSide += ... com um método sem parâmetro (por exemplo, o MinigamePanel), o Console acusa o erro na linha. Troque o método para receber int side e ignore o valor.
MonsterTurn.cs: a mensagem da esquiva. No método Show, troque o ctx.Log do caso QteResult.Dodge:
ctx.Log(o.Type switch
{
ReactionType.Jump => $"Você saltou sobre o golpe rasteiro de {monster.Name} (+1 Fúria)!",
ReactionType.Duck => $"Você se abaixou e o golpe alto de {monster.Name} passou por cima!",
_ => $"Você deslizou para o lado e o golpe de {monster.Name} passou reto!",
});
O AutoPlayer não precisa mudar: para qualquer reação que não é parry, ele já devolve Dodge quando acerta.
🧪 Teste rápido
No BattleTestStarter, ponha Level 3 e escolha uma masmorra cujo chefe seja o Arqueiro Ancestral (com o AutoPlayer desligado, para reagir você mesmo). Na onda 3, o círculo pede << DESVIE, DESVIE >> e ABAIXE. Com o teclado: ← e → no lado certo esquivam e no lado errado levam o dano; ↓ abaixa.
✅ Checkpoint da Fase 1
- Todos os scripts das Fases 1 a 8 do Guerreiro compilam com os namespaces
OldKnight.*. - O seeder cria 19 comuns, 8 chefes (com o Arqueiro Ancestral) e os dois heróis.
- Abaixar e lateral com lado funcionam no toque e no teclado.
- Os testes do Guerreiro passam (Window → General → Test Runner → EditMode → Run All).
- Commit:
feat: motor do Guerreiro com elenco e reações do app.
Problemas comuns
The type or namespace name 'GuerreiroDaMasmorra' could not be found: algum arquivo escapou do Replace in Files (geralmente umusingem script de teste). ProcureGuerreiroDaMasmorraemAssets/_Project.OKWnão aparece no menu: o seeder não está numa pastaEditor, ou há erro de compilação em outro script. Corrija o Console primeiro.- O lado errado esquiva mesmo assim: o golpe foi criado com
S(qualquer lado) em vez deS</S>. Confira a notação no seeder e rode o menu de novo.
Próxima fase: Fase 2 — Palco HD-2D.
Parte 5 — Implementação Unity: Fase 2 (Palco HD-2D)
Objetivo desta fase: a batalha sai do palco 2D e vai para um corredor 3D iluminado (estrada, margens ou paredes, tochas que tremulam, névoa no fundo), com os personagens em sprites 2D de pé no chão, recebendo luz e projetando sombra. As regras não mudam em nada.
Conceitos novos:
- Shader Graph: um editor visual de shaders. Aqui ele cria um material de sprite que recebe luz 3D. O material de sprite padrão é "unlit": ignora as tochas e deixaria os personagens chapados no meio do cenário iluminado.
- Alpha Clipping: pixels com transparência abaixo de um limite são descartados por inteiro. Para pixel art isso é perfeito (borda dura), e ainda deixa o sprite projetar uma sombra com o recorte certo.
- Luz adicional (Point Light): uma luz que irradia de um ponto, como uma tocha. O URP no celular aceita poucas por objeto (a Etapa 0B configurou 4).
- Névoa (Fog): o URP mistura uma cor com o que está longe da câmera. Esconde o fim do corredor e dá profundidade.
- Billboard: um objeto que gira para ficar de frente para a câmera.
- Raycast: um raio que sai da câmera pelo ponto tocado e diz em que collider bateu. Substitui o
Physics2D.OverlapPointdo palco 2D.
Etapa 2A — O material de sprite iluminado
-
Em
Art/Shaders: botão direito → Create → Shader Graph → URP → Lit Shader Graph, nomeSpriteLit. Duplo clique para abrir. -
Graph Inspector → Graph Settings (a aba do painel da direita):
- Surface Type
Opaque; - Alpha Clipping marcado;
- Render Face
Both; - Cast Shadows marcado.
- Surface Type
-
No Blackboard (painel da esquerda), + → Texture2D, nome
MainTex. No Graph Inspector → Node Settings, Reference =_MainTex. É o nome que oSpriteRendererusa para mandar o quadro atual ao material. -
Monte os nós (espaço no grafo → Create Node, digite o nome):
- Arraste
MainTexpara o grafo → ligue em um Sample Texture 2D (entrada Texture). - Vertex Color (a cor do
SpriteRenderer, usada no piscar de dano) → Multiply com a saída RGBA do Sample. - A saída do Multiply vai para Base Color do Fragment.
- Um Split na saída do Multiply → a saída A vai para Alpha do Fragment.
- Alpha Clip Threshold do Fragment =
0.5.
- Arraste
-
Normais. O sprite não tem normais próprias, e sem elas a luz fica errada (o sprite aparece preto ou piscando). No contexto Vertex, ligue:
- um Vector 3
(0, 0, -1)em Normal; - um Vector 3
(1, 0, 0)em Tangent.
Nos dois, deixe o espaço em Object. Assim a placa "aponta" para a câmera.
- um Vector 3
-
Save Asset. Em
Art/Materials: Create → Material, nomeM_SpriteLit, Shader =Shader Graphs/SpriteLit. -
Material do chão: Create → Material, nome
M_Ground, Shader =Universal Render Pipeline/Lit, Smoothness0. Os materiais do corredor são cópias dele criadas por código.
Importação da pixel art (PixelLab): selecione os PNGs dos personagens → Texture Type Sprite (2D and UI), Sprite Mode Multiple, Pixels Per Unit 100, Filter Mode Point (no filter), Compression None → Apply. No Sprite Editor, Slice → Grid By Cell Size com o tamanho do quadro e Pivot Bottom. As texturas de chão, margem e parede: Texture Type Default, Wrap Mode Repeat, Filter Mode Point, Compression None.
🧪 Teste rápido
Crie um Point Light e um objeto com Sprite Renderer usando qualquer sprite e o material M_SpriteLit. Mova a luz: o sprite clareia do lado da luz e escurece longe dela. Apague os dois objetos.
Etapa 2B — O corredor 3D
Cada bioma (Parte 1 §9.2) vira um asset com as texturas e as cores dele.
// Caminho: Assets/_Project/Scripts/Data/BiomeDefinition.cs
using UnityEngine;
namespace OldKnight.Data
{
// POR QUE: um bioma da Parte 1 §9.2 (Castelo, Floresta, Cemitério,
// Ruínas e os interiores Cripta, Igreja Profanada, Salão do Castelo).
// ESTRATÉGIA: só aparência. Ao ar livre há margens com objetos de
// cena, céu e lua; nos interiores, paredes, teto e tochas, sem céu. O
// CorridorBuilder lê este asset e monta o corredor.
[CreateAssetMenu(fileName = "Biome", menuName = "OKW/Biome")]
public class BiomeDefinition : ScriptableObject
{
public string id;
public string displayName;
public bool interior;
[Header("Texturas (Wrap Repeat, Filter Point)")]
public Texture2D road;
public Texture2D verge;
public Texture2D wall;
public Texture2D ceiling;
[Header("Objetos de cena nas margens (só ao ar livre)")]
public Sprite[] props;
[Range(0f, 1f)] public float propChance = 0.35f;
[Tooltip("Silhueta no horizonte (opcional).")]
public Sprite skyline;
[Header("Luz e névoa")]
public Color sky = new Color(0.08f, 0.09f, 0.16f);
public Color ambient = new Color(0.35f, 0.36f, 0.45f);
public Color fog = new Color(0.07f, 0.08f, 0.14f);
public float moonIntensity = 0.6f;
[Tooltip("Uma tocha a cada N fileiras (0 = nenhuma).")]
public int torchEvery = 4;
public Color torchColor = new Color(1f, 0.62f, 0.3f);
}
}
// Caminho: Assets/_Project/Scripts/Battle/TorchFlicker.cs
using UnityEngine;
namespace OldKnight.Battle
{
// POR QUE: tocha parada parece lâmpada. A luz que tremula é o que vende
// o corredor iluminado a fogo (Parte 1 §10).
// ESTRATÉGIA: soma duas ondas de ruído de Perlin na intensidade; cada
// tocha começa num ponto diferente do ruído para não piscarem juntas.
[RequireComponent(typeof(Light))]
public class TorchFlicker : MonoBehaviour
{
[SerializeField] private float _base = 2.2f;
[SerializeField] private float _amount = 0.6f;
[SerializeField] private float _speed = 3f;
private Light _light;
private float _seed;
private void Awake()
{
_light = GetComponent<Light>();
_seed = Random.value * 100f;
}
// Varia a intensidade a cada quadro.
private void Update()
{
float t = Time.time * _speed + _seed;
float noise = Mathf.PerlinNoise(t, 0f) * 0.7f + Mathf.PerlinNoise(t * 2.7f, 1f) * 0.3f;
_light.intensity = _base + (noise - 0.5f) * 2f * _amount;
}
}
}
// Caminho: Assets/_Project/Scripts/Battle/SpriteBillboard.cs
using UnityEngine;
namespace OldKnight.Battle
{
// POR QUE: um sprite de lado vira uma linha fina. Personagens, objetos
// de cena e chamas ficam sempre de frente para a câmera.
// ESTRATÉGIA: gira só em torno do eixo vertical (yaw), para o sprite
// continuar em pé no chão quando a câmera olha um pouco para baixo.
public class SpriteBillboard : MonoBehaviour
{
private Transform _camera;
// Copia a direção da câmera, sem a inclinação.
private void LateUpdate()
{
if (_camera == null)
{
if (Camera.main == null) return;
_camera = Camera.main.transform;
}
Vector3 forward = _camera.forward;
forward.y = 0f;
if (forward.sqrMagnitude > 0.0001f) transform.rotation = Quaternion.LookRotation(forward);
}
}
}
// Caminho: Assets/_Project/Scripts/Battle/CorridorBuilder.cs
using System.Collections.Generic;
using OldKnight.Data;
using UnityEngine;
using UnityEngine.Rendering;
namespace OldKnight.Battle
{
// POR QUE: o cenário 3D da Parte 1 §10: uma estrada que segue para o
// fundo, margens com objetos ao ar livre ou paredes e teto nos
// interiores, tochas e névoa. Entre as ondas, a marcha faz o chão andar
// para trás (como no app).
// ESTRATÉGIA: o corredor é uma fila de "fileiras" de 2 × 2 unidades ao
// longo do eixo Z, cada uma com seus quads (estrada, margens ou paredes,
// teto). Na marcha, as fileiras andam para −Z; a que passa da câmera
// pula para o fim da fila. Nada é criado durante a marcha.
public class CorridorBuilder : MonoBehaviour
{
[SerializeField] private Material _groundMaterial;
[SerializeField] private Material _spriteMaterial;
[SerializeField] private Light _moon;
[SerializeField] private Camera _camera;
[SerializeField] private float _tile = 2f;
[SerializeField] private int _rows = 18;
[SerializeField] private float _startZ = -6f;
[SerializeField] private float _roadHalfWidth = 2f;
[SerializeField] private float _vergeWidth = 6f;
[SerializeField] private float _wallHeight = 4.5f;
[SerializeField] private float _marchSpeed = 4f;
private readonly List<Transform> _rowRoots = new List<Transform>();
private readonly List<Material> _materials = new List<Material>();
private bool _scrolling;
// Monta o corredor de um bioma (apaga o anterior).
public void Build(BiomeDefinition biome, System.Random rng)
{
Clear();
var road = MaterialFor(biome.road);
var verge = MaterialFor(biome.verge != null ? biome.verge : biome.road);
var wall = MaterialFor(biome.wall != null ? biome.wall : biome.road);
var ceiling = MaterialFor(biome.ceiling != null ? biome.ceiling : biome.wall);
for (int i = 0; i < _rows; i++)
{
var row = new GameObject($"Row{i}").transform;
row.SetParent(transform, false);
row.localPosition = new Vector3(0f, 0f, _startZ + i * _tile);
_rowRoots.Add(row);
Quad(row, road, new Vector3(0f, 0f, 0f), new Vector3(90f, 0f, 0f), new Vector2(_roadHalfWidth * 2f, _tile));
if (biome.interior)
{
Quad(row, wall, new Vector3(-_roadHalfWidth - 0.5f, _wallHeight / 2f, 0f), new Vector3(0f, -90f, 0f), new Vector2(_tile, _wallHeight));
Quad(row, wall, new Vector3(_roadHalfWidth + 0.5f, _wallHeight / 2f, 0f), new Vector3(0f, 90f, 0f), new Vector2(_tile, _wallHeight));
Quad(row, ceiling, new Vector3(0f, _wallHeight, 0f), new Vector3(-90f, 0f, 0f), new Vector2(_roadHalfWidth * 2f + 1f, _tile));
if (biome.torchEvery > 0 && i % biome.torchEvery == 0) Torch(row, biome, i / biome.torchEvery % 2 == 0 ? -1 : 1);
}
else
{
float x = _roadHalfWidth + _vergeWidth / 2f;
Quad(row, verge, new Vector3(-x, -0.01f, 0f), new Vector3(90f, 0f, 0f), new Vector2(_vergeWidth, _tile));
Quad(row, verge, new Vector3(x, -0.01f, 0f), new Vector3(90f, 0f, 0f), new Vector2(_vergeWidth, _tile));
for (int side = -1; side <= 1; side += 2)
{
if (biome.props == null || biome.props.Length == 0 || rng.NextDouble() > biome.propChance) continue;
var sprite = biome.props[rng.Next(biome.props.Length)];
float px = side * (_roadHalfWidth + 0.8f + (float)rng.NextDouble() * (_vergeWidth - 1.6f));
Prop(row, sprite, new Vector3(px, 0f, (float)rng.NextDouble() * _tile - _tile / 2f));
}
}
}
if (biome.skyline != null && !biome.interior) Skyline(biome.skyline);
ApplyAtmosphere(biome);
}
// Liga ou desliga a marcha (o chão anda para trás).
public void SetScrolling(bool on) => _scrolling = on;
// Anda as fileiras e recicla a que passou da câmera.
private void Update()
{
if (!_scrolling) return;
float step = _marchSpeed * Time.deltaTime;
float length = _rows * _tile;
foreach (var row in _rowRoots)
{
var p = row.localPosition;
p.z -= step;
if (p.z < _startZ - _tile) p.z += length;
row.localPosition = p;
}
}
// Céu, luz ambiente, lua e névoa do bioma.
private void ApplyAtmosphere(BiomeDefinition biome)
{
_camera.clearFlags = CameraClearFlags.SolidColor;
_camera.backgroundColor = biome.interior ? biome.fog : biome.sky;
RenderSettings.ambientMode = AmbientMode.Flat;
RenderSettings.ambientLight = biome.ambient;
RenderSettings.fog = true;
RenderSettings.fogMode = FogMode.Linear;
RenderSettings.fogColor = biome.fog;
RenderSettings.fogStartDistance = 8f;
RenderSettings.fogEndDistance = _rows * _tile + _startZ;
_moon.intensity = biome.interior ? 0f : biome.moonIntensity;
}
// Um quad com material, posição, rotação e tamanho; sem collider.
private void Quad(Transform parent, Material material, Vector3 position, Vector3 euler, Vector2 size)
{
var quad = GameObject.CreatePrimitive(PrimitiveType.Quad);
Destroy(quad.GetComponent<Collider>());
quad.transform.SetParent(parent, false);
quad.transform.localPosition = position;
quad.transform.localEulerAngles = euler;
quad.transform.localScale = new Vector3(size.x, size.y, 1f);
var renderer = quad.GetComponent<MeshRenderer>();
renderer.sharedMaterial = material;
renderer.shadowCastingMode = ShadowCastingMode.Off;
}
// Um objeto de cena (árvore, lápide, coluna) em pé na margem.
private void Prop(Transform parent, Sprite sprite, Vector3 position)
{
var go = new GameObject(sprite.name);
go.transform.SetParent(parent, false);
go.transform.localPosition = position;
var renderer = go.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sharedMaterial = _spriteMaterial;
renderer.shadowCastingMode = ShadowCastingMode.On;
renderer.receiveShadows = true;
go.AddComponent<SpriteBillboard>();
}
// Uma tocha na parede: chama em sprite (opcional) e a luz que tremula.
private void Torch(Transform parent, BiomeDefinition biome, int side)
{
var go = new GameObject("Torch");
go.transform.SetParent(parent, false);
go.transform.localPosition = new Vector3(side * (_roadHalfWidth + 0.3f), 2.4f, 0f);
var light = go.AddComponent<Light>();
light.type = LightType.Point;
light.color = biome.torchColor;
light.range = 7f;
light.shadows = LightShadows.None;
go.AddComponent<TorchFlicker>();
}
// A silhueta do horizonte, longe e grande, atrás da névoa.
private void Skyline(Sprite sprite)
{
var go = new GameObject("Skyline");
go.transform.SetParent(transform, false);
go.transform.localPosition = new Vector3(0f, -1f, _startZ + _rows * _tile + 4f);
go.transform.localScale = Vector3.one * 6f;
go.AddComponent<SpriteRenderer>().sprite = sprite;
}
// Uma cópia do material de chão com a textura dada.
private Material MaterialFor(Texture2D texture)
{
var material = new Material(_groundMaterial) { mainTexture = texture };
_materials.Add(material);
return material;
}
// Apaga o corredor anterior e os materiais criados para ele.
private void Clear()
{
foreach (Transform child in transform) Destroy(child.gameObject);
foreach (var material in _materials) Destroy(material);
_rowRoots.Clear();
_materials.Clear();
}
}
}
Os assets de bioma: em Data/Biomes, Create → OKW → Biome para cada um dos 7 biomas (castle, forest, graveyard, ruins, crypt, church, hall), com as texturas do PixelLab (Parte 1 §10). Os três interiores têm Interior marcado e Moon Intensity 0. Sem textura nova ainda, um bioma pode usar qualquer textura de pedra como estrada, para testar.
🧪 Teste rápido
Numa cena vazia, um objeto Corridor com Corridor Builder (ligue Ground Material = M_Ground, Sprite Material = M_SpriteLit, Moon = a Directional Light, Camera = a Main Camera) e um script de teste de uma linha no Start: GetComponent<CorridorBuilder>().Build(biome, new System.Random()). Dê Play com um bioma interior: paredes, teto e tochas tremulando somem na névoa. Marque Scrolling chamando SetScrolling(true): o chão anda sem buracos. Apague o script de teste.
Etapa 2C — Personagens no chão 3D
O CombatantView e o BattleStage do guia do Guerreiro são substituídos pelos de baixo. Os métodos públicos são os mesmos, mais SetDimmed (o personagem da reserva, Fase 3), então nenhum outro script muda.
// Caminho: Assets/_Project/Scripts/Battle/CombatantView.cs
using System.Threading;
using OldKnight.Combat;
using OldKnight.Data;
using TMPro;
using UnityEngine;
using UnityEngine.Rendering;
namespace OldKnight.Battle
{
// POR QUE: um personagem na tela (herói ou monstro), agora de pé no chão
// 3D: sprite iluminado com sombra, barra de HP, linha de status, marcador
// de alvo e área de toque.
// ESTRATÉGIA: só aparência, como no guia do Guerreiro. O objeto fica no
// chão (y = 0) e gira para a câmera (SpriteBillboard). Os movimentos
// são `async` e acontecem no plano XZ: X para os lados, Z para o fundo
// do corredor. A área de toque é um BoxCollider 3D, para o raycast.
[RequireComponent(typeof(BoxCollider))]
public class CombatantView : MonoBehaviour
{
private static readonly Color HitTint = new Color(1f, 0.35f, 0.35f);
private static readonly Color DimTint = new Color(0.55f, 0.55f, 0.6f);
[SerializeField] private SpriteAnimPlayer _body;
[SerializeField] private HpBar _hpBar;
[SerializeField] private TextMeshPro _status;
[SerializeField] private GameObject _targetMarker;
[SerializeField] private Sprite _fallbackSprite;
private SpriteAnimSet _anims;
private Color _baseColor = Color.white;
private float _flashUntil;
private bool _dimmed;
// Null quando a view é de um herói.
public Monster Monster { get; private set; }
public Vector3 Home { get; set; }
public float Height { get; private set; }
// Prepara a view; sem animações, usa o quadrado reserva tingido.
public void Init(SpriteAnimSet anims, float height, bool faceLeft, Monster monster, Color fallbackTint)
{
Monster = monster;
Height = height;
_body.Configure(height, Random.value);
_body.Renderer.flipX = faceLeft;
_body.Renderer.shadowCastingMode = ShadowCastingMode.On;
_body.Renderer.receiveShadows = true;
bool hasArt = anims != null && anims.idle != null;
_anims = hasArt ? anims : null;
_baseColor = hasArt ? Color.white : fallbackTint;
if (hasArt) _body.Play(anims.idle);
else _body.ShowStatic(_fallbackSprite, fallbackTint);
var hitbox = GetComponent<BoxCollider>();
hitbox.size = new Vector3(height * 0.6f, height * 0.9f, 0.4f);
hitbox.center = new Vector3(0f, height * 0.45f, 0f);
_hpBar.SetSize(height * 0.6f, 0.12f);
_hpBar.transform.localPosition = new Vector3(0f, height * 1.02f, 0f);
_status.transform.localPosition = new Vector3(0f, height * 1.15f, 0f);
SetTargeted(false);
}
// Vira para a esquerda ou para a direita.
public void Face(bool left) => _body.Renderer.flipX = left;
// Atualiza barra de HP e linha de status.
public void SetBars(float hpFraction, string status)
{
_hpBar.SetFraction(hpFraction);
_status.text = status;
}
// Liga ou desliga o marcador de alvo.
public void SetTargeted(bool on)
{
if (_targetMarker != null) _targetMarker.SetActive(on);
}
// Esmaece o personagem da reserva (Parte 1 §10) e esconde a barra dele.
public void SetDimmed(bool on)
{
_dimmed = on;
_hpBar.gameObject.SetActive(!on);
}
// Pisca vermelho por um instante (levou dano).
public void Flash() => _flashUntil = Time.time + 0.25f;
// Corre até `target` em `seconds` segundos, com aceleração suave.
public async Awaitable MoveTo(Vector3 target, float seconds, CancellationToken token)
{
if (_anims != null) _body.Play(_anims.run);
// No corredor quase tudo é ida e volta em Z; só vira se o passo
// para o lado for visível.
float dx = target.x - transform.position.x;
if (Mathf.Abs(dx) > 0.3f) Face(dx < 0f);
Vector3 from = transform.position;
for (float t = 0f; t < seconds; t += Time.deltaTime)
{
transform.position = Vector3.Lerp(from, target, Mathf.SmoothStep(0f, 1f, t / seconds));
await Awaitable.NextFrameAsync(token);
}
transform.position = target;
if (_anims != null) _body.Play(_anims.idle);
}
// Toca o golpe uma vez e volta a ficar parado.
public Awaitable Strike(CancellationToken token) => PlayOnce(_anims?.attack, token);
// Pose de defesa no parry (ou o golpe, se não houver pose de guarda).
public Awaitable Guard(CancellationToken token) =>
PlayOnce(_anims != null && _anims.guard != null ? _anims.guard : _anims?.attack, token);
// Corre sem sair do lugar (marcha entre ondas).
public void Run(bool on)
{
if (_anims != null) _body.Play(on ? _anims.run : _anims.idle);
}
// Toca uma animação uma vez e espera ela acabar.
private async Awaitable PlayOnce(SpriteAnimation anim, CancellationToken token)
{
if (anim == null)
{
await Awaitable.WaitForSecondsAsync(0.3f, token);
return;
}
_body.Play(anim, restart: true);
await Awaitable.WaitForSecondsAsync(anim.Duration, token);
if (_anims != null) _body.Play(_anims.idle);
}
// Aplica o piscar de dano e o esmaecido da reserva.
private void Update()
{
var color = _dimmed ? _baseColor * DimTint : _baseColor;
_body.Renderer.color = Time.time < _flashUntil ? HitTint : color;
}
}
}
// Caminho: Assets/_Project/Scripts/Battle/BattleStage.cs
using System.Collections.Generic;
using System.Threading;
using OldKnight.Combat;
using OldKnight.Data;
using UnityEngine;
namespace OldKnight.Battle
{
// POR QUE: o "palco" da batalha, agora no corredor 3D: cria as views,
// sabe onde cada um fica, anima entradas, idas e voltas, mostra números
// flutuantes e treme a câmera. O código dos turnos pede coisas ao palco
// e nunca mexe em Transform diretamente.
// ESTRATÉGIA: as posições vêm de objetos vazios no chão (spots). As
// views de herói ficam num dicionário por HeroRun; `HeroView` é sempre a
// de quem está em campo (ctx.Hero), então MonsterTurn e WarriorTurn não
// sabem que existe uma dupla. Na Fase 3, Bind passa a criar as duas
// views e SwapHeroes encena a troca. O toque vira um raycast da câmera.
public class BattleStage : MonoBehaviour
{
public static readonly Color White = Color.white;
public static readonly Color Gold = new Color(0.98f, 0.75f, 0.14f);
public static readonly Color Red = new Color(0.97f, 0.44f, 0.44f);
public static readonly Color Green = new Color(0.29f, 0.87f, 0.5f);
public static readonly Color Blue = new Color(0.38f, 0.65f, 0.98f);
public static readonly Color Purple = new Color(0.65f, 0.55f, 0.98f);
public static readonly Color Orange = new Color(0.98f, 0.57f, 0.24f);
public static readonly Color Cyan = new Color(0.38f, 0.84f, 1f);
[SerializeField] private Camera _camera;
[SerializeField] private VisualCatalog _catalog;
[SerializeField] private CombatantView _viewPrefab;
[SerializeField] private FloatingText _floatPrefab;
[SerializeField] private CorridorBuilder _corridor;
[SerializeField] private BiomeDefinition[] _biomes;
[SerializeField] private Transform _heroSpot;
[SerializeField] private Transform _reserveSpot;
[SerializeField] private Transform[] _monsterSpots;
[SerializeField] private Transform _monsterEntrance;
[SerializeField] private float _reach = 1.3f;
private readonly Dictionary<Monster, CombatantView> _monsters = new Dictionary<Monster, CombatantView>();
private readonly Dictionary<HeroRun, CombatantView> _heroes = new Dictionary<HeroRun, CombatantView>();
private BattleContext _context;
private Vector3 _cameraHome;
private float _shakeUntil;
// A view de quem está em campo.
public CombatantView HeroView => _context != null && _heroes.TryGetValue(_context.Hero, out var view) ? view : null;
// Unity chama ao criar o objeto.
private void Awake()
{
_cameraHome = _camera.transform.position;
}
// Liga o palco a uma masmorra: monta o corredor, cria o herói e escuta as mortes.
public void Bind(BattleContext context)
{
_context = context;
context.Slain += OnSlain;
BuildCorridor(context);
AddHero(context.Hero, _heroSpot.position, false);
}
// Sorteia o bioma da masmorra e monta o corredor (Parte 1 §9.2).
private void BuildCorridor(BattleContext context)
{
if (_corridor == null || _biomes == null || _biomes.Length == 0) return;
var rng = new System.Random(context.Request.Level * 7919 + context.Request.NgPlus);
_corridor.Build(_biomes[rng.Next(_biomes.Length)], rng);
}
// Cria a view de um herói num lugar.
private CombatantView AddHero(HeroRun hero, Vector3 position, bool dimmed)
{
var definition = hero.Loadout.Hero;
var entry = _catalog.Find(definition.id, _context.Request.Color);
var view = Instantiate(_viewPrefab, position, Quaternion.identity, transform);
view.Init(entry?.anims, entry != null ? entry.height : 2.2f, false, null, Blue);
view.Home = position;
view.SetDimmed(dimmed);
_heroes[hero] = view;
return view;
}
// Os monstros de uma onda entram correndo do fundo do corredor.
public async Awaitable SpawnWave(IReadOnlyList<Monster> monsters, CancellationToken token)
{
foreach (var view in _monsters.Values) Destroy(view.gameObject);
_monsters.Clear();
var entrances = new List<Awaitable>();
for (int i = 0; i < monsters.Count; i++)
{
var monster = monsters[i];
var entry = _catalog.Find(monster.Id, monster.Color);
var view = Instantiate(_viewPrefab, _monsterEntrance.position + Vector3.right * ((i - 1) * 1.5f), Quaternion.identity, transform);
view.Init(entry?.anims, entry != null ? entry.height : (monster.IsBoss ? 3.4f : 2.4f), false, monster,
monster.IsBoss ? Red : Orange);
view.Home = _monsterSpots[Mathf.Min(i, _monsterSpots.Length - 1)].position;
_monsters[monster] = view;
entrances.Add(view.MoveTo(view.Home, 0.9f, token));
}
foreach (var entrance in entrances) await entrance;
}
// A view de um monstro (null se não existir).
public CombatantView ViewOf(Monster monster) =>
monster != null && _monsters.TryGetValue(monster, out var view) ? view : null;
// O monstro vivo sob um ponto da tela (null se nenhum).
public Monster MonsterAt(Vector2 screenPosition)
{
var view = ViewAt(screenPosition);
return view != null && view.Monster != null && !view.Monster.Dead ? view.Monster : null;
}
// O dedo está em cima de quem está em campo?
public bool IsHeroAt(Vector2 screenPosition)
{
var view = ViewAt(screenPosition);
return view != null && view == HeroView;
}
// Converte um ponto da tela para o mundo, num plano de frente para a
// câmera que passa pelo herói (é onde a pedrada desenha a mira).
public Vector3 ScreenToWorld(Vector2 screenPosition)
{
var ray = _camera.ScreenPointToRay(screenPosition);
var origin = HeroView != null ? HeroView.transform.position + Vector3.up : Vector3.up;
var plane = new Plane(-_camera.transform.forward, origin);
return plane.Raycast(ray, out float distance) ? ray.GetPoint(distance) : origin;
}
// O herói corre até ficar de frente para o monstro (do lado da câmera).
public Awaitable HeroApproach(Monster target, CancellationToken token) =>
HeroView.MoveTo(ViewOf(target).transform.position + Vector3.back * _reach, 0.34f, token);
// O herói corre até o meio dos monstros (golpes em todos).
public Awaitable HeroToCenter(CancellationToken token)
{
Vector3 sum = Vector3.zero;
foreach (var spot in _monsterSpots) sum += spot.position;
return HeroView.MoveTo(sum / _monsterSpots.Length + Vector3.back * (_reach + 0.3f), 0.3f, token);
}
// O herói volta para o seu lugar.
public Awaitable HeroReturn(CancellationToken token) => HeroView.MoveTo(HeroView.Home, 0.34f, token);
// O monstro corre até o herói.
public Awaitable MonsterApproach(Monster monster, CancellationToken token) =>
ViewOf(monster).MoveTo(HeroView.transform.position + Vector3.forward * _reach, 0.38f, token);
// O monstro volta para o seu lugar.
public Awaitable MonsterReturn(Monster monster, CancellationToken token)
{
var view = ViewOf(monster);
return view.MoveTo(view.Home, 0.38f, token);
}
// Marcha entre ondas: o chão anda e quem está em campo corre no lugar.
public async Awaitable March(float seconds, CancellationToken token)
{
foreach (var view in _heroes.Values) view.Run(true);
if (_corridor != null) _corridor.SetScrolling(true);
await Awaitable.WaitForSecondsAsync(seconds, token);
if (_corridor != null) _corridor.SetScrolling(false);
foreach (var view in _heroes.Values) view.Run(false);
}
// Texto flutuante sobre um monstro, ou sobre quem está em campo (monster null).
public void Float(Monster monster, string text, Color color, bool big = false)
{
var view = monster == null ? HeroView : ViewOf(monster);
if (view == null) return;
var label = Instantiate(_floatPrefab, view.transform.position + Vector3.up * view.Height * 0.8f, _camera.transform.rotation);
label.Show(text, color, big);
}
// Mostra o resultado de um golpe do herói: dano, escudos quebrados, perfuração.
public void ShowStrike(Monster monster, StrikeOutcome strike, Color color, bool big)
{
ViewOf(monster)?.Flash();
if (strike.ShieldsBroken > 0)
{
Float(monster, $"ESCUDO -{strike.ShieldsBroken}", Blue, big);
if (strike.Pierced > 0) Float(monster, $"{strike.Pierced} PERFURA", Cyan);
}
else
{
Float(monster, strike.Damage.ToString(), color, big);
}
}
// Treme a câmera por 0,4 s.
public void Shake() => _shakeUntil = Time.time + 0.4f;
// A view sob um ponto da tela, pelo raycast da câmera.
private CombatantView ViewAt(Vector2 screenPosition)
{
var ray = _camera.ScreenPointToRay(screenPosition);
return Physics.Raycast(ray, out var hit, 100f) ? hit.collider.GetComponent<CombatantView>() : null;
}
// Monstro morto: mostra o ouro.
private void OnSlain(Monster monster)
{
Float(monster, $"+{monster.Gold} OURO", Gold);
Shake();
}
// Todo quadro: barras, status, alvo e tremida.
private void LateUpdate()
{
if (_context != null)
{
foreach (var pair in _monsters)
{
var monster = pair.Key;
var view = pair.Value;
if (monster.Dead)
{
view.gameObject.SetActive(false);
continue;
}
view.SetBars((float)monster.Hp / monster.MaxHp, StatusOf(monster));
view.SetTargeted(_context.ShowTarget && monster == _context.Target);
}
foreach (var pair in _heroes) pair.Value.SetBars((float)pair.Key.Hp / pair.Key.MaxHp, "");
}
_camera.transform.position = Time.time < _shakeUntil
? _cameraHome + (Vector3)(Random.insideUnitCircle * 0.12f)
: _cameraHome;
}
// "ESCUDO 4 FASE 2 CANSAÇO 3/5" ou "EXAUSTO".
private string StatusOf(Monster monster)
{
string shield = monster.Shield > 0 ? $"ESCUDO {monster.Shield} " : "";
string rage = monster.Enraged ? "FASE 2 " : "";
if (monster.Exhausted) return $"{shield}{rage}EXAUSTO";
int cap = DungeonMath.FatigueCap(monster.IsBoss, _context.EffectiveLevel, _context.Balance);
return $"{shield}{rage}CANSAÇO {monster.Fatigue}/{cap}";
}
}
}
Mudanças nos prefabs (os do guia do Guerreiro, Etapa 3B):
CombatantView: Remove Component no Box Collider 2D e Add Component → Box Collider (3D), com Is Trigger marcado. Add Component → Sprite Billboard. No filhoBody, Sprite Renderer → Material =M_SpriteLit. OTargetMarkervira um anel no chão: rotação(90, 0, 0), posição(0, 0.02, 0), escala(0.9, 0.9, 1).FloatingText: nada muda (o palco o cria já virado para a câmera).
Etapa 2D — Câmera, luz e cena
Na cena Battle:
-
Main Camera: Projection
Perspective, Field of View50, Position(0, 3.2, -5.2), Rotation(18, 0, 0). Ela fica atrás e acima de quem está em campo, olhando o corredor (Parte 1 §10). -
Directional Light (a lua): Rotation
(40, -30, 0), Color azulada#9DB4FF, Shadow TypeSoft Shadows. -
Apague o
[World]2D do guia do Guerreiro e crie um novo, com os spots no chão (y = 0):Objeto Posição HeroSpot(0, 0, 0)ReserveSpot(-1.6, 0, -1.3)MonsterSpot1(-1.7, 0, 6.5)MonsterSpot2(0, 0, 7.6)MonsterSpot3(1.7, 0, 6.5)MonsterEntrance(0, 0, 18) -
Dentro de
[World]:Corridorcom o Corridor Builder (ligado como no teste da Etapa 2B) eBattleStagecom o novo Battle Stage. Ligue Camera, Catalog, View Prefab, Float Prefab, Corridor, Biomes (os 7 assets), os spots e Monster Entrance.
🧪 Teste rápido
Abra Battle direto e dê Play. O Cavaleiro aparece de costas no primeiro plano, os monstros entram correndo do fundo do corredor e param em leque. Tocar num monstro o seleciona (o anel aparece no chão dele). Entre as ondas, o chão anda para trás enquanto o Cavaleiro corre no lugar. Num bioma interior, as tochas iluminam os sprites de um lado só.
✅ Checkpoint da Fase 2
- Personagens de pé no chão 3D, com luz das tochas e sombra.
- Toque no monstro funciona pelo raycast; a pedrada mira no plano do herói.
- Corredor dos 7 biomas, com névoa e marcha.
- Commit:
feat: palco HD-2D.
Problemas comuns
- Sprites pretos: faltam as normais do contexto Vertex do
SpriteLit(Etapa 2A, passo 5), ou o material doBodyainda é o padrão. - Sprite com franja cinza em volta: o PNG está com Filter Mode
Bilinear. UsePoint (no filter)e CompressionNone. - Tocar no monstro não seleciona: o prefab ainda tem o Box Collider 2D. O raycast 3D só vê o Box Collider.
- O chão longe "cintila" na marcha: é o filtro
Pointsem mipmaps. Nas texturas de chão, margem e parede (só nelas, não nos sprites), marque Generate Mip Maps.
Próxima fase: Fase 3 — A dupla.
Parte 6 — Implementação Unity: Fase 3 (A dupla)
Objetivo desta fase: o Cavaleiro e a Bruxa na mesma masmorra, um em campo e o outro na reserva (Parte 1 §4). A fila segue a velocidade e as vagas de quem está em campo; Trocar gasta o turno; quem cai é substituído na hora; a derrota só vem quando os dois caem. A Bruxa ainda joga com o motor do Guerreiro (ela ganha o turno de cartas na Fase 4).
Conceitos novos:
- Propriedade que delega:
Herodeixa de ser um campo e viraParty.Active. Todo código que lêctx.Heropassa a ler "quem está em campo agora" sem mudar uma linha. - Objeto compartilhado por referência: as poções da dupla ficam num objeto
Suppliesque os doisHeroRunapontam. Gastar uma poção com um personagem tira do mesmo estoque do outro.
Etapa 3A — O estado da dupla
HeroRun.cs: arquivo inteiro. Mudam três coisas em relação ao guia do Guerreiro: o HeroLoadout ganha os números da Bruxa, as poções passam a ser da dupla (Supplies) e a Fúria vira mana quando quem ganha é a Bruxa (Parte 1 §5).
// Caminho: Assets/_Project/Scripts/Combat/HeroRun.cs
using System;
using OldKnight.Data;
namespace OldKnight.Combat
{
// POR QUE: o que um personagem leva para a masmorra: atributos
// comprados, Habilidade, gema equipada e, para a Bruxa, mão e mana.
// ESTRATÉGIA: montado pelo acampamento a partir do save (Fase 5) ou à
// mão nos testes. `UnlockAllSkills` serve ao treino e aos testes.
public sealed class HeroLoadout
{
public HeroDefinition Hero;
public int MaxHp;
public int Attack;
public int Defense;
public int DefensePurchases;
public int SkillLevel;
public bool HasGem;
public GemId Gem;
public int GemLevelValue;
public bool UnlockAllSkills;
// Bruxa (Parte 1 §7.1). A árvore de Souls (Fase 6) mexe nestes números.
public int HandSize;
public int ManaPerTurn;
public int MaxMana;
public int StartMana;
// Nível da gema em efeito: 0 se ela não for a equipada.
public int GemLevel(GemId id) => HasGem && Gem == id ? GemLevelValue : 0;
// Quantos especiais a Habilidade já liberou.
public int SkillsUnlocked(int step) => UnlockAllSkills ? 99 : SkillLevel / step;
// Loadout de um personagem novo, só com os atributos iniciais.
public static HeroLoadout Fresh(HeroDefinition hero) => new HeroLoadout
{
Hero = hero,
MaxHp = hero.maxHp,
Attack = hero.attack,
Defense = hero.defense,
HandSize = hero.handSize,
ManaPerTurn = hero.manaPerTurn,
MaxMana = hero.maxMana,
};
}
public enum CurseType
{
Drain,
SealFury,
}
// POR QUE: encanto do Cultista sobre o herói (GuerreiroDaMasmorra.md §8.5).
public sealed class Curse
{
public CurseType Type;
public int Turns;
public int Power;
}
// POR QUE: o que a dupla gasta junto (Parte 1 §4.3): Poções de Cura e
// de Adrenalina.
// ESTRATÉGIA: um objeto só, apontado pelos dois HeroRun. Fora da dupla
// (testes do Guerreiro), cada HeroRun tem o seu.
public sealed class Supplies
{
public int Potions;
public int Adrenaline;
}
// POR QUE: o estado de um personagem durante uma masmorra: HP, Fúria (ou
// mana), guarda, dinamites, maldição, ouro coletado e se a run segue
// perfeita.
// ESTRATÉGIA: nasce cheio a cada masmorra e morre com ela. As poções
// moram no `Supplies` (da dupla); `Potions` e `Adrenaline` continuam
// existindo como propriedades, para o código do Guerreiro não mudar.
public sealed class HeroRun
{
public readonly HeroLoadout Loadout;
public int Hp;
public int Fury;
public int Mana;
public bool Exhausted;
public bool GuardBroken;
public double Stagger;
public int Dynamite;
public int DynamitePower;
public Curse Curse;
public bool Flawless = true;
public int RunGold;
public int RocksLeft;
// Véu Cinzento da Bruxa (a Couraça Arcana do Mago): absorve dano do próximo golpe.
public int Ward;
public Supplies Supplies;
// Começa a masmorra com HP cheio e as poções da Parte 1 §4.3.
public HeroRun(HeroLoadout loadout, GameBalance balance)
{
Loadout = loadout;
Hp = loadout.MaxHp;
Mana = loadout.StartMana;
Supplies = new Supplies { Potions = balance.potionsPerRun, Adrenaline = balance.adrenalinePerRun };
}
public int MaxHp => Loadout.MaxHp;
public int Attack => Loadout.Attack;
public int Defense => Loadout.Defense;
public bool Dead => Hp <= 0;
public bool IsWitch => Loadout.Hero.heroClass == HeroClass.Witch;
public string Name => Loadout.Hero.displayName;
public int Potions
{
get => Supplies.Potions;
set => Supplies.Potions = value;
}
public int Adrenaline
{
get => Supplies.Adrenaline;
set => Supplies.Adrenaline = value;
}
// Segmentos da barra de guarda: 5, +1 a cada 5 compras de Defesa, teto 20.
public int StaggerCap => Math.Min(20, 5 + Loadout.DefensePurchases / 5);
public bool FurySealed => Curse != null && Curse.Type == CurseType.SealFury;
// +n de Fúria (teto 5), a menos que o Selo da Fúria esteja ativo. Na
// Bruxa, o mesmo ganho vira mana (Parte 1 §5).
public void GainFury(int amount = 1)
{
if (IsWitch)
{
GainMana(amount);
return;
}
if (FurySealed) return;
Fury = Math.Min(5, Fury + amount);
}
// Gasta só o que usou: a barra não zera.
public void SpendFury(int amount)
{
Fury = Math.Max(0, Fury - amount);
}
// +n de mana, até o teto.
public void GainMana(int amount)
{
Mana = Math.Min(Loadout.MaxMana, Mana + amount);
}
// Soma dano à barra de guarda. Devolve true se a guarda quebrou.
public bool AddStagger(int damage, bool inCombo)
{
if (Stagger >= StaggerCap && inCombo)
{
Stagger = 0;
Exhausted = true;
GuardBroken = true;
return true;
}
Stagger = Math.Min(StaggerCap, Stagger + Math.Max(0.3, damage * 7.0 / MaxHp));
return false;
}
// Tira HP; qualquer dano acaba com a run perfeita.
public void TakeDamage(int amount)
{
if (amount <= 0) return;
Flawless = false;
Hp = Math.Max(0, Hp - amount);
}
// Cura até o máximo e devolve quanto curou de fato.
public int Heal(int amount)
{
int before = Hp;
Hp = Math.Min(MaxHp, Hp + amount);
return Hp - before;
}
// Gruda uma dinamite (teto `max`); a potência é a maior já registrada.
public void AddDynamite(int monsterAttack, int max)
{
if (Dynamite >= max) return;
Dynamite++;
DynamitePower = Math.Max(DynamitePower, CombatMath.DynamitePower(monsterAttack));
}
}
}
// Caminho: Assets/_Project/Scripts/Combat/PartyRun.cs
using System.Collections.Generic;
namespace OldKnight.Combat
{
// POR QUE: a dupla durante uma masmorra (Parte 1 §4.1): quem está em
// campo, quem está na reserva, a troca e a derrota só com os dois caídos.
// ESTRATÉGIA: uma lista de 1 ou 2 HeroRun e o índice de quem está em
// campo. As poções são as do líder (Supplies compartilhado). A troca
// zera a barra de guarda de quem entra ("entra frio"); dinamites,
// maldição e exaustão ficam com quem os recebeu. Quem cai por último é
// sempre quem está em campo, então a Soul levanta o `Active`.
public sealed class PartyRun
{
private readonly List<HeroRun> _members = new List<HeroRun>();
public int ActiveIndex { get; private set; }
public int Swaps { get; private set; }
// Monta a dupla; `partner` null = herói sozinho (testes, treino).
public PartyRun(HeroRun leader, HeroRun partner)
{
_members.Add(leader);
if (partner == null) return;
partner.Supplies = leader.Supplies;
_members.Add(partner);
}
public IReadOnlyList<HeroRun> Members => _members;
public HeroRun Active => _members[ActiveIndex];
public HeroRun Reserve => _members.Count > 1 ? _members[1 - ActiveIndex] : null;
public bool CanSwap => Reserve != null && !Reserve.Dead;
public bool AllDead => _members.TrueForAll(m => m.Dead);
// Ouro somado dos dois (cada um guarda o que matou).
public int RunGold
{
get
{
int sum = 0;
foreach (var member in _members) sum += member.RunGold;
return sum;
}
}
// Perfeita só se nenhum dos dois sofreu dano (Parte 1 §4.3).
public bool Flawless => _members.TrueForAll(m => m.Flawless);
// Troca quem está em campo. Devolve false se não há com quem trocar.
public bool Swap()
{
if (!CanSwap) return false;
ActiveIndex = 1 - ActiveIndex;
Active.Stagger = 0;
Active.GuardBroken = false;
Swaps++;
return true;
}
// A Soul: quem está em campo (o último a cair) volta com HP cheio.
public void ReviveActive()
{
Active.Hp = Active.MaxHp;
}
}
}
BattleContext.cs: arquivo inteiro (DungeonRequest ganha Partner; o contexto ganha Party; Hero vira "quem está em campo").
// Caminho: Assets/_Project/Scripts/Battle/BattleContext.cs
using System;
using System.Collections.Generic;
using OldKnight.Combat;
using OldKnight.Data;
namespace OldKnight.Battle
{
// POR QUE: o pedido de entrada numa masmorra: com que dupla, em que
// nível e quantas Souls e Mapas o jogador tem para gastar lá dentro.
// ESTRATÉGIA: `Loadout` é quem começa em campo e `Partner` quem começa
// na reserva (null = herói sozinho). `GemsUnlocked` decide qual
// Pergaminho de Gema cai. Os campos de duelo do guia do Guerreiro
// ficaram: o fluxo deles não é usado, mas o ApplyOutcome ainda os lê.
public sealed class DungeonRequest
{
public HeroLoadout Loadout;
public HeroLoadout Partner;
public int Level = 1;
public int NgPlus;
public DungeonColor Color;
public int Souls;
public int Maps;
public bool Tutorial;
public List<GemId> GemsUnlocked = new List<GemId>();
public string DuelHeroId;
public Func<IRandom, List<Monster>> ThirdWaveOverride;
}
public enum DungeonOutcomeKind
{
Victory,
Defeat,
Fled,
}
// POR QUE: como a masmorra terminou, para o painel de resultado e o
// acampamento aplicarem ouro, Souls e Mapas.
public sealed class BattleEnd
{
public DungeonOutcomeKind Kind;
public DungeonRequest Request;
public int RunGold;
public bool Flawless;
public int SoulsUsed;
public int MapsUsed;
public int Wave;
public int Swaps;
}
// POR QUE: tudo o que os turnos compartilham durante uma masmorra: a
// dupla, os monstros da onda, o alvo, o sorteio e as explosões pendentes.
// ESTRATÉGIA: `Hero` é quem está em campo (`Party.Active`), então o
// turno do monstro, o QTE e o HUD do Guerreiro funcionam sem saber da
// dupla. `Slay` é o único jeito de matar um monstro: paga o ouro a quem
// está em campo, enfileira a Explosão Final e avisa a tela.
public sealed class BattleContext
{
public readonly GameDatabase Db;
public readonly DungeonRequest Request;
public readonly IRandom Rng;
public readonly PartyRun Party;
public readonly Queue<Monster> PendingBooms = new Queue<Monster>();
public List<Monster> Monsters = new List<Monster>();
public int Wave;
public Monster Target;
public bool ShowTarget;
public int SoulsUsed;
public int MapsUsed;
public event Action<string> Logged;
public event Action<Monster> Slain;
// Começa uma masmorra com a dupla cheia.
public BattleContext(GameDatabase db, DungeonRequest request, IRandom rng)
{
Db = db;
Request = request;
Rng = rng;
var leader = new HeroRun(request.Loadout, db.balance);
var partner = request.Partner != null ? new HeroRun(request.Partner, db.balance) : null;
Party = new PartyRun(leader, partner);
}
public HeroRun Hero => Party.Active;
public GameBalance Balance => Db.balance;
public int NgPlus => Request.NgPlus;
public int EffectiveLevel => DungeonMath.EffectiveLevel(Request.Level, Request.NgPlus, Balance);
public bool WaveCleared => Monsters.TrueForAll(m => m.Dead);
public int SoulsAvailable => Request.Souls - SoulsUsed;
public int MapsAvailable => Request.Maps - MapsUsed;
// Monstros vivos, na ordem da onda.
public List<Monster> Alive() => Monsters.FindAll(m => !m.Dead);
// Mostra uma mensagem na linha de log.
public void Log(string message) => Logged?.Invoke(message);
// Mata um monstro (uma vez só): ouro, explosão pendente e aviso à tela.
public void Slay(Monster monster)
{
if (monster.Dead) return;
monster.Dead = true;
monster.Hp = 0;
Hero.RunGold += monster.Gold;
if (monster.Fire && !Request.Tutorial) PendingBooms.Enqueue(monster);
Slain?.Invoke(monster);
}
// Garante um alvo vivo.
public void EnsureTarget()
{
if (Target == null || Target.Dead) Target = Monsters.Find(m => !m.Dead);
}
// Troca de alvo para o próximo (+1) ou anterior (-1) vivo.
public void CycleTarget(int direction)
{
var alive = Alive();
if (alive.Count == 0) return;
int index = alive.IndexOf(Target);
index = ((index + direction) % alive.Count + alive.Count) % alive.Count;
Target = alive[index];
}
}
}
BattleInterfaces.cs: acrescente HandOff no fim do TurnResult. É o que a carta Passo Trocado (Fase 4) devolve: "troquei, e quem entrou age agora".
public enum TurnResult
{
Done,
Fled,
HandOff,
}
🧪 Teste rápido
O Console compila. Os testes do Guerreiro continuam passando: eles criam HeroRun sozinho, e Potions e Adrenaline ainda existem.
Etapa 3B — A fila pela velocidade de quem está em campo
Initiative.cs: troque a classe Initiative (o TurnSlot fica igual) por esta. A versão antiga continua existindo, com a velocidade do GameBalance, para os testes do Guerreiro.
// POR QUE: a fila de iniciativa (GuerreiroDaMasmorra.md §4) com o ritmo
// da dupla (Parte 1 §4.2): a vaga do herói usa a velocidade de quem está
// em campo, e a Bruxa tem duas vagas.
// ESTRATÉGIA: cada vaga rola velocidade + d(0..5); a vaga extra perde
// `extraTurnPenalty` por vez. As vagas do herói entram primeiro na lista
// e a ordenação é estável, então num empate o herói vai antes, como no
// jogo original. Chefe em Fase 2 entra 2–3 vezes com +3.
public static class Initiative
{
// A fila do guia do Guerreiro: uma vaga com a velocidade do GameBalance.
public static List<TurnSlot> Roll(IReadOnlyList<Monster> monsters, IRandom rng, GameBalance b) =>
Roll(monsters, null, rng, b);
// A fila desta rodada, do mais rápido para o mais lento.
public static List<TurnSlot> Roll(IReadOnlyList<Monster> monsters, HeroDefinition hero, IRandom rng, GameBalance b)
{
int speed = hero != null && hero.speed > 0 ? hero.speed : b.heroSpeed;
int turns = hero != null ? System.Math.Max(1, hero.turnsPerRound) : 1;
int penalty = hero != null ? hero.extraTurnPenalty : 0;
var entries = new List<(TurnSlot slot, int roll)>();
for (int t = 0; t < turns; t++)
entries.Add((new TurnSlot(null), speed + rng.Range(0, 5) - t * penalty));
foreach (var monster in monsters)
{
if (monster.Dead) continue;
int times = monster.Enraged ? rng.Range(2, 3) : 1;
for (int i = 0; i < times; i++)
{
int roll = monster.Definition.speed + rng.Range(0, 5) + (monster.Enraged ? 3 : 0);
entries.Add((new TurnSlot(monster), roll));
}
}
return entries.OrderByDescending(e => e.roll).Select(e => e.slot).ToList();
}
}
Etapa 3C — O controlador com a dupla
// Caminho: Assets/_Project/Scripts/Battle/PartySwap.cs
using OldKnight.Combat;
using UnityEngine;
namespace OldKnight.Battle
{
// POR QUE: a troca encenada (Parte 1 §4.1), usada pelo comando Trocar,
// pela carta Passo Trocado e pela troca forçada quando alguém cai.
// ESTRATÉGIA: troca primeiro no estado (PartyRun) e depois anima: os
// dois correm cruzando de lugar. O log diz por que a troca aconteceu.
public class PartySwap : MonoBehaviour
{
[SerializeField] private BattleStage _stage;
// Troca quem está em campo; `forced` = quem estava caiu.
public async Awaitable Swap(BattleContext ctx, bool forced)
{
var leaving = ctx.Hero;
if (!ctx.Party.Swap()) return;
var entering = ctx.Hero;
ctx.Log(forced
? $"{leaving.Name} caiu! {entering.Name} assume a frente!"
: $"{entering.Name} troca de lugar com {leaving.Name}.");
await _stage.SwapHeroes(leaving, entering, destroyCancellationToken);
}
}
}
BattleStage.cs (o da Fase 2): troque o método Bind e acrescente SwapHeroes logo depois dele.
// Liga o palco a uma masmorra: monta o corredor, cria a dupla (quem
// está em campo no HeroSpot, o outro esmaecido no ReserveSpot) e
// escuta as mortes.
public void Bind(BattleContext context)
{
_context = context;
context.Slain += OnSlain;
BuildCorridor(context);
foreach (var hero in context.Party.Members)
{
bool inField = hero == context.Hero;
AddHero(hero, inField ? _heroSpot.position : _reserveSpot.position, !inField);
}
}
// Os dois correm cruzando de lugar; quem sai fica esmaecido na reserva.
public async Awaitable SwapHeroes(HeroRun leaving, HeroRun entering, CancellationToken token)
{
var outView = _heroes[leaving];
var inView = _heroes[entering];
outView.Home = _reserveSpot.position;
inView.Home = _heroSpot.position;
inView.SetDimmed(false);
var goOut = outView.MoveTo(outView.Home, 0.35f, token);
var goIn = inView.MoveTo(inView.Home, 0.35f, token);
await goOut;
await goIn;
outView.SetDimmed(true);
}
BattleController.cs: arquivo inteiro.
// Caminho: Assets/_Project/Scripts/Battle/BattleController.cs
using System;
using System.Threading;
using OldKnight.Combat;
using OldKnight.Data;
using UnityEngine;
namespace OldKnight.Battle
{
// POR QUE: o roteiro de uma masmorra (GuerreiroDaMasmorra.md §4 e §9)
// com a dupla (Parte 1 §4): 3 ondas; em cada uma, rodadas com fila de
// iniciativa até a onda cair, os dois caírem ou a dupla fugir.
// ESTRATÉGIA: um único método async lido de cima para baixo, como no
// guia do Guerreiro. As diferenças: a fila usa quem está em campo; o
// turno do herói vai para o WarriorTurn ou o WitchTurn pela classe; se
// quem está em campo cai e o outro está de pé, a troca é forçada e a
// luta segue; só com os dois caídos vem a Soul ou a derrota.
public class BattleController : MonoBehaviour
{
[SerializeField] private GameDatabase _database;
[SerializeField] private BattleStage _stage;
[SerializeField] private BattleHud _hud;
[SerializeField] private PartyHud _partyHud;
[SerializeField] private MonsterTurn _monsterTurn;
[SerializeField] private BoomResolver _booms;
[SerializeField] private PartySwap _swap;
[SerializeField] private MonoBehaviour _warriorTurn;
[SerializeField] private MonoBehaviour _witchTurn;
[SerializeField] private MonoBehaviour _deathHandler;
public BattleContext Context { get; private set; }
// Disparado uma vez, quando a masmorra termina.
public event Action<BattleEnd> Ended;
// Ponto de entrada: roda a masmorra e avisa o fim.
public async void Begin(DungeonRequest request)
{
try
{
var end = await RunDungeon(request, destroyCancellationToken);
Ended?.Invoke(end);
}
catch (OperationCanceledException)
{
// A cena fechou no meio da luta: nada a fazer.
}
}
// As 3 ondas.
private async Awaitable<BattleEnd> RunDungeon(DungeonRequest request, CancellationToken token)
{
Context = new BattleContext(_database, request, new SystemRandom());
_hud.Bind(Context);
if (_partyHud != null) _partyHud.Bind(Context);
_stage.Bind(Context);
for (int wave = 1; wave <= 3; wave++)
{
Context.Wave = wave;
Context.Monsters = wave == 3 && request.ThirdWaveOverride != null
? request.ThirdWaveOverride(Context.Rng)
: WaveFactory.Make(wave, request.Level, request.NgPlus, request.Color, _database, Context.Rng);
Context.Target = null;
Context.EnsureTarget();
Context.Log(wave == 3
? $"{Context.Monsters[0].Name} surge das sombras{(Context.Monsters.Count > 1 ? " com sua escolta" : "")}!"
: $"Onda {wave}/3: monstros à vista!");
await _stage.SpawnWave(Context.Monsters, token);
var outcome = await FightWave(token);
if (outcome.HasValue) return End(outcome.Value);
if (wave < 3)
{
Context.Log("Onda vencida! Avançando pelo corredor...");
await Awaitable.WaitForSecondsAsync(0.7f, token);
await _stage.March(2f, token);
}
}
return End(DungeonOutcomeKind.Victory);
}
// Rodadas até a onda cair (null) ou a masmorra acabar (derrota/fuga).
private async Awaitable<DungeonOutcomeKind?> FightWave(CancellationToken token)
{
while (true)
{
// A fila é montada com quem está em campo no começo da rodada
// e não muda com uma troca (Parte 1 §4.2).
var queue = Initiative.Roll(Context.Monsters, Context.Hero.Loadout.Hero, Context.Rng, Context.Balance);
for (int i = 0; i < queue.Count; i++)
{
if (Context.WaveCleared) return null;
var slot = queue[i];
_hud.ShowQueue(queue, i);
if (slot.IsHero)
{
if (await HeroTurn(token) == TurnResult.Fled) return DungeonOutcomeKind.Fled;
}
else if (!slot.Monster.Dead)
{
await _monsterTurn.TakeTurn(Context, slot.Monster);
}
await _booms.ProcessAll(Context);
if (Context.Hero.Dead)
{
if (Context.Party.CanSwap)
{
await _swap.Swap(Context, forced: true); // o outro entra sozinho, sem gastar nada
}
else
{
if (await OfferRevive()) break; // reviveu: nova rodada
return DungeonOutcomeKind.Defeat;
}
}
if (Context.WaveCleared) return null;
}
}
}
// Começo do turno de quem está em campo (dinamite, maldição, exaustão) e a ação.
private async Awaitable<TurnResult> HeroTurn(CancellationToken token)
{
var hero = Context.Hero;
if (hero.Dynamite > 0)
{
hero.Dynamite--;
hero.TakeDamage(hero.DynamitePower);
_stage.HeroView.Flash();
_stage.Shake();
_stage.Float(null, $"-{hero.DynamitePower} DINAMITE", BattleStage.Orange, true);
Context.Log($"Uma dinamite explodiu em {hero.Name}! {hero.DynamitePower} de dano." +
(hero.Dynamite > 0 ? $" Restam {hero.Dynamite}!" : ""));
if (hero.Dead) return TurnResult.Done;
await Awaitable.WaitForSecondsAsync(0.6f, token);
}
else
{
hero.DynamitePower = 0;
}
if (hero.Curse != null)
{
if (hero.Curse.Type == CurseType.Drain)
{
hero.TakeDamage(hero.Curse.Power);
_stage.Float(null, $"-{hero.Curse.Power} MALDIÇÃO", BattleStage.Purple, true);
Context.Log($"A maldição drena {hero.Curse.Power} de HP!");
if (hero.Dead) return TurnResult.Done;
}
if (--hero.Curse.Turns <= 0)
{
hero.Curse = null;
_stage.Float(null, "encanto dissipado", BattleStage.White);
}
}
if (hero.Exhausted)
{
hero.Exhausted = false;
Context.Log($"Exausto pelo golpe especial, {hero.Name} não consegue agir!");
_stage.Float(null, "EXAUSTO...", BattleStage.White, true);
await Awaitable.WaitForSecondsAsync(1.1f, token);
return TurnResult.Done;
}
hero.GuardBroken = false;
hero.RocksLeft = CombatMath.RocksPerRound(hero.Loadout.GemLevel(GemId.Stone));
Context.EnsureTarget();
var result = await TakerFor(hero).TakeTurn(Context);
// Passo Trocado: a troca já aconteceu e quem entrou age nesta mesma vaga.
if (result == TurnResult.HandOff && !Context.Hero.Dead && !Context.WaveCleared) return await HeroTurn(token);
return result == TurnResult.HandOff ? TurnResult.Done : result;
}
// O turno certo para a classe de quem está em campo.
private IHeroTurnTaker TakerFor(HeroRun hero)
{
bool witch = hero.IsWitch && _witchTurn != null;
return (witch ? _witchTurn : _warriorTurn) as IHeroTurnTaker;
}
// Os dois caídos: oferece Soul (se houver quem ofereça e Soul para gastar).
private async Awaitable<bool> OfferRevive()
{
var handler = _deathHandler as IDeathHandler;
if (handler == null || Context.SoulsAvailable <= 0) return false;
if (!await handler.OfferRevive(Context)) return false;
Context.SoulsUsed++;
Context.Party.ReviveActive();
_stage.Float(null, $"+{Context.Hero.MaxHp} SOUL", BattleStage.Cyan, true);
Context.Log($"A Soul envolve {Context.Hero.Name} em luz... de pé, com a vida cheia!");
return true;
}
// Monta o resumo do fim, com o ouro e a run perfeita da dupla.
private BattleEnd End(DungeonOutcomeKind kind) => new BattleEnd
{
Kind = kind,
Request = Context.Request,
RunGold = Context.Party.RunGold,
Flawless = Context.Party.Flawless,
SoulsUsed = Context.SoulsUsed,
MapsUsed = Context.MapsUsed,
Wave = Context.Wave,
Swaps = Context.Party.Swaps,
};
// Fase 2 dos chefes, conferida a todo quadro (GuerreiroDaMasmorra.md §8.4).
private void Update()
{
if (Context == null) return;
foreach (var monster in Context.Monsters)
{
if (!monster.ShouldEnrage) continue;
monster.Enrage(CombatMath.EnrageAttackMult);
_stage.Float(monster, "FASE 2!", BattleStage.Red, true);
_stage.Shake();
Context.Log($"{monster.Name} entra em FÚRIA! Golpes mais fortes, mais rápidos e turnos extras!");
}
}
}
}
O controlador já referencia o
PartyHud(Etapa 3E). Crie o script da Etapa 3E antes de voltar para a Unity, ou o Console acusa erro até lá.
ResultPanel.cs: o painel de derrota diz "o herói caiu". Procure os textos com "herói" no método OfferRevive e troque por "a dupla caiu". O fluxo não muda.
Etapa 3D — O comando Trocar
CommandMenu.cs, cinco mudanças:
-
Swapno fim doHeroCommand(depois deFlee). -
Em
CommandOptions, três campos:public bool HasPartner;public bool CanSwap;public string SwapLabel; -
Um campo:
[SerializeField] private HoldButton _swap; -
No
Awake:_swap.Held += () => Complete(HeroCommand.Swap); -
No
WaitForCommand, antes de_root.SetActive(true):_swap.gameObject.SetActive(o.HasPartner);_swap.Interactable = o.CanSwap;_swap.SetLabel(o.SwapLabel);
WarriorTurn.cs, três mudanças:
-
Um campo:
[SerializeField] private PartySwap _swapper; -
No
Options, dentro donew CommandOptions { ... }:HasPartner = ctx.Party.Reserve != null,CanSwap = ctx.Party.CanSwap,SwapLabel = ctx.Party.Reserve != null ? $"TROCAR: {ctx.Party.Reserve.Name.ToUpperInvariant()}" : "", -
No
switchdoTakeTurn, antes docase HeroCommand.Flee::case HeroCommand.Swap: await _swapper.Swap(ctx, forced: false); break;
BattleTestStarter.cs: um campo [SerializeField] private string _partnerId = "witch"; e, no new DungeonRequest { ... }:
Partner = string.IsNullOrEmpty(_partnerId) ? null : HeroLoadout.Fresh(_database.Hero(_partnerId)),
BattleHud.cs: o HUD do Guerreiro mostra "VOCÊ" na fila e só a Fúria. No ShowQueue, troque "VOCÊ" por _context.Hero.Name.ToUpperInvariant(). No LateUpdate, troque as duas linhas da Fúria por:
_furyLabel.text = hero.IsWitch ? $"MANA {hero.Mana}/{hero.Loadout.MaxMana}"
: hero.FurySealed ? "FÚRIA SELADA" : $"FÚRIA {hero.Fury}/5";
_furyFill.fillAmount = hero.IsWitch ? (float)hero.Mana / Mathf.Max(1, hero.Loadout.MaxMana) : hero.Fury / 5f;
E o ouro do chip passa a ser o da dupla: troque OURO {hero.RunGold} por OURO {_context.Party.RunGold}.
Etapa 3E — O painel da dupla
// Caminho: Assets/_Project/Scripts/Battle/PartyHud.cs
using System;
using OldKnight.Combat;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace OldKnight.Battle
{
// POR QUE: o painel da dupla na base da tela (Parte 1 §11): retrato,
// nome e HP dos dois, com destaque em quem está em campo. É o que deixa
// o jogador decidir a troca sem abrir menu nenhum.
// ESTRATÉGIA: um "slot" de interface por membro da dupla, na ordem da
// PartyRun; o LateUpdate copia HP e quem está em campo. Retratos vêm de
// uma lista (id do herói → sprite), montada no Inspector.
public class PartyHud : MonoBehaviour
{
[Serializable]
private class Slot
{
public GameObject root;
public Image portrait;
public Image hpFill;
public TMP_Text label;
public GameObject activeFrame;
}
[Serializable]
private class Portrait
{
public string heroId;
public Sprite sprite;
}
[SerializeField] private Slot[] _slots = new Slot[2];
[SerializeField] private Portrait[] _portraits;
private BattleContext _context;
// Liga o painel a uma masmorra.
public void Bind(BattleContext context)
{
_context = context;
var members = context.Party.Members;
for (int i = 0; i < _slots.Length; i++)
{
bool used = i < members.Count;
_slots[i].root.SetActive(used);
if (used) _slots[i].portrait.sprite = PortraitOf(members[i].Loadout.Hero.id);
}
}
// HP, quem está em campo e quem caiu.
private void LateUpdate()
{
if (_context == null) return;
var members = _context.Party.Members;
for (int i = 0; i < members.Count && i < _slots.Length; i++)
{
var hero = members[i];
var slot = _slots[i];
slot.hpFill.fillAmount = (float)hero.Hp / hero.MaxHp;
slot.label.text = hero.Dead ? $"{hero.Name} CAÍDO" : $"{hero.Name} {hero.Hp}/{hero.MaxHp}";
slot.activeFrame.SetActive(hero == _context.Hero);
slot.portrait.color = hero.Dead ? new Color(0.3f, 0.3f, 0.3f) : Color.white;
}
}
// O retrato de um herói (null se não houver).
private Sprite PortraitOf(string heroId)
{
foreach (var p in _portraits) if (p.heroId == heroId) return p.sprite;
return null;
}
}
}
Montar na cena Battle:
- No Canvas
[UI], no canto inferior esquerdo,PartyHud(Add Component → Party Hud) com um Horizontal Layout Group e dois filhos iguais,Slot0eSlot1, cada um260 × 120:Frame(UI → Image, borda dourada, esticada; é o Active Frame);Portrait(UI → Image,96 × 96, à esquerda);HpFill(UI → Image, Filled, Horizontal, verde);Label(texto26).
- Ligue os dois slots e preencha Portraits com
knightewitch(sprites de retrato do PixelLab, ou a pose parada por enquanto). - No
CommandMenu, acrescente um HoldButtonSwap("TROCAR") ao Vertical Layout Group doRoote ligue em Swap. - Em
[Systems]: objetoPartySwap(Party Swap, com Stage). No Battle Controller, ligue Party Hud e Swap. No Warrior Turn, ligue Swapper. O Witch Turn fica vazio até a Fase 4.
🧪 Teste rápido
Abra Battle direto (o starter manda a dupla). O Cavaleiro está em campo e a Bruxa esmaecida atrás, à esquerda.
- No turno do Cavaleiro, segure TROCAR: os dois cruzam de lugar e o turno acaba. Na rodada seguinte, a fila mostra BRUXA duas vezes.
- Os monstros só atacam quem está em campo: a barra de HP de quem está na reserva não muda.
- Deixe o AutoPlayer com Skill
0até alguém cair: o outro entra sozinho ("caiu! ... assume a frente!"). A derrota só aparece quando os dois caem.
✅ Checkpoint da Fase 3
- A fila segue quem está em campo: 1 vaga lenta para o Cavaleiro e 2 rápidas para a Bruxa.
- Trocar gasta o turno; a troca forçada não gasta nada; derrota só com os dois caídos.
- Poções compartilhadas: a Bruxa bebe do mesmo estoque do Cavaleiro.
- Commit:
feat: dupla com troca.
Problemas comuns
NullReferenceExceptionemHeroViewna troca: oBattleStageainda tem oBindda Fase 2, que só cria quem está em campo. Use oBinddesta fase.- A Bruxa age uma vez só por rodada: o asset
Data/Heroes/witchficou com Turns Per Round1. Rode OKW → Criar ou atualizar dados iniciais de novo. - A fila não muda depois da troca: está certo. A fila da rodada é montada no começo dela (Parte 1 §4.2); a mudança aparece na rodada seguinte.
Próxima fase: Fase 4 — A Bruxa.
Parte 7 — Implementação Unity: Fase 4 (A Bruxa)
Objetivo desta fase: a Bruxa joga com o deck e a mana da Parte 1 §7. No turno dela, a mão aparece na base da tela: tocar numa carta mostra o efeito, segurar 0,5 s a joga no alvo atual, e ela joga quantas a mana pagar antes de Encerrar.
Conceitos novos:
- Monte de compra, mão e descarte: as três pilhas de um deckbuilder. Quando o monte de compra acaba, o descarte é embaralhado e vira o novo monte.
- Embaralhar com Fisher-Yates: percorre a lista do fim para o começo e troca cada carta com uma posição sorteada antes dela. Todas as ordens ficam igualmente prováveis, e com o
IRandomroteirizado dos testes o resultado é previsível. - Uma carta = um asset: cada carta é um
ScriptableObject. O deck é uma lista de referências para esses assets; duas Faíscas são duas referências para o mesmo asset.
Etapa 4A — As cartas como dados
// Caminho: Assets/_Project/Scripts/Data/CardDefinition.cs
using UnityEngine;
namespace OldKnight.Data
{
// POR QUE: em quem a carta age (Parte 1 §7.2). Bounce = golpes em alvos
// sorteados entre os vivos, podendo repetir.
public enum CardTarget
{
Single,
All,
Bounce,
Self,
Partner,
None,
}
// POR QUE: o efeito principal da carta. Utility = só os extras (mana e
// compra); HandOff = a troca do Passo Trocado.
public enum CardEffect
{
Damage,
Ward,
Heal,
Utility,
HandOff,
}
// POR QUE: uma carta da Bruxa (Parte 1 §7.2).
// ESTRATÉGIA: `power` multiplica o ATK da Bruxa (dano, escudo ou cura).
// `manaGain` e `draw` são extras que qualquer carta pode ter (a Pressa
// usa os dois). O turno lê só estes campos: carta nova não exige código
// novo, desde que use estes efeitos.
[CreateAssetMenu(fileName = "Card", menuName = "OKW/Card")]
public class CardDefinition : ScriptableObject
{
public string id;
public string displayName;
[TextArea] public string description;
public int cost;
public CardTarget target;
public CardEffect effect;
[Tooltip("Multiplicador do ATK da Bruxa.")]
public float power;
public int hits = 1;
[Tooltip("Cansaço extra em cada alvo atingido.")]
public int fatigue;
public int manaGain;
public int draw;
public Sprite art;
}
}
GameDatabase.cs: acrescente as listas das cartas e a busca pelo id:
[Header("Cartas da Bruxa (Parte 1 §7.2)")]
public List<CardDefinition> cards = new List<CardDefinition>();
[Tooltip("O deck inicial, com as cópias repetidas.")]
public List<CardDefinition> starterDeck = new List<CardDefinition>();
[Tooltip("O conjunto de onde saem as recompensas.")]
public List<CardDefinition> rewardPool = new List<CardDefinition>();
// Carta pelo id gravado no save (null se não existir mais).
public CardDefinition Card(string id) => cards.Find(c => c.id == id);
GameDataSeeder.cs: antes do bloco var db = LoadOrCreate<GameDatabase>(...), acrescente as cartas da Parte 1 §7.2:
// Parte 1 §7.2 — as cartas da Bruxa (proposta do GDD).
var cards = new List<CardDefinition>();
CardDefinition Card(string id, string name, string description, int cost, CardTarget target,
CardEffect effect, float power, int hits = 1, int fatigue = 0, int manaGain = 0, int draw = 0)
{
var c = LoadOrCreate<CardDefinition>($"{Root}/Cards/{id}.asset");
c.id = id;
c.displayName = name;
c.description = description;
c.cost = cost;
c.target = target;
c.effect = effect;
c.power = power;
c.hits = hits;
c.fatigue = fatigue;
c.manaGain = manaGain;
c.draw = draw;
EditorUtility.SetDirty(c);
cards.Add(c);
return c;
}
var spark = Card("spark", "Faísca", "ATK x0,6 em 1 alvo", 0, CardTarget.Single, CardEffect.Damage, 0.6f);
var embers = Card("embers", "Chuva de Brasas", "ATK x0,5 em todos", 1, CardTarget.All, CardEffect.Damage, 0.5f);
var veil = Card("veil", "Véu Cinzento", "Escudo de ATK x1,0", 1, CardTarget.Self, CardEffect.Ward, 1f);
var snap = Card("snap", "Estalo Duplo", "2 golpes de ATK x0,5 em alvos sorteados", 1, CardTarget.Bounce, CardEffect.Damage, 0.5f, hits: 2);
var rewards = new List<CardDefinition>
{
Card("chain", "Raio em Cadeia", "3 golpes de ATK x0,45 em alvos sorteados", 1, CardTarget.Bounce, CardEffect.Damage, 0.45f, hits: 3),
Card("blizzard", "Nevasca", "ATK x0,7 em todos, +1 de cansaço", 2, CardTarget.All, CardEffect.Damage, 0.7f, fatigue: 1),
Card("blast", "Explosão Arcana", "ATK x1,1 em todos", 2, CardTarget.All, CardEffect.Damage, 1.1f),
Card("lance", "Lança de Luz", "ATK x1,4 em 1 alvo", 1, CardTarget.Single, CardEffect.Damage, 1.4f),
Card("tea", "Chá de Ervas", "Cura ATK x1,5", 1, CardTarget.Self, CardEffect.Heal, 1.5f),
Card("blessing", "Bênção ao Mestre", "Cura o parceiro na reserva em ATK x1,5", 1, CardTarget.Partner, CardEffect.Heal, 1.5f),
Card("haste", "Pressa", "+1 mana e compra 1 carta", 0, CardTarget.None, CardEffect.Utility, 0f, manaGain: 1, draw: 1),
Card("memory", "Lembrança", "Compra 2 cartas", 1, CardTarget.None, CardEffect.Utility, 0f, draw: 2),
Card("swapstep", "Passo Trocado", "Troca com o parceiro, que age na hora", 0, CardTarget.None, CardEffect.HandOff, 0f),
};
var starter = new List<CardDefinition>
{
spark, spark, spark, spark, embers, embers, embers, veil, veil, snap,
};
E, junto das outras atribuições ao db:
db.cards = cards;
db.starterDeck = starter;
db.rewardPool = rewards;
🧪 Teste rápido
OKW → Criar ou atualizar dados iniciais. A pasta Data/Cards tem 13 cartas, e o GameDatabase mostra Starter Deck com 10 itens (4 Faíscas).
Etapa 4B — Deck e mana em C# puro
HeroRun.cs: o HeroLoadout ganha o deck. Acrescente no topo using System.Collections.Generic; e, nos campos da Bruxa:
public List<CardDefinition> Deck = new List<CardDefinition>();
// Caminho: Assets/_Project/Scripts/Combat/WitchDeck.cs
using System.Collections.Generic;
using OldKnight.Data;
namespace OldKnight.Combat
{
// POR QUE: as três pilhas da Bruxa (Parte 1 §7.1): monte de compra, mão
// e descarte, durante uma masmorra inteira (passa de uma onda para a
// outra).
// ESTRATÉGIA: listas de referências para os assets de carta. Comprar com
// o monte vazio embaralha o descarte e o transforma no monte. Jogar uma
// carta a move da mão para o descarte pela posição na mão (há cópias
// iguais). O embaralhar usa o IRandom, para os testes serem previsíveis.
public sealed class WitchDeck
{
public readonly List<CardDefinition> DrawPile = new List<CardDefinition>();
public readonly List<CardDefinition> Hand = new List<CardDefinition>();
public readonly List<CardDefinition> Discard = new List<CardDefinition>();
private readonly IRandom _rng;
// Começa a masmorra com o deck inteiro embaralhado no monte.
public WitchDeck(IEnumerable<CardDefinition> deck, IRandom rng)
{
_rng = rng;
DrawPile.AddRange(deck);
Shuffle(DrawPile, rng);
}
// Compra até a mão ter `handSize` cartas. Devolve quantas comprou.
public int DrawUpTo(int handSize)
{
int drawn = 0;
while (Hand.Count < handSize && Draw()) drawn++;
return drawn;
}
// Compra uma carta (false se monte e descarte estão vazios).
public bool Draw()
{
if (DrawPile.Count == 0)
{
if (Discard.Count == 0) return false;
DrawPile.AddRange(Discard);
Discard.Clear();
Shuffle(DrawPile, _rng);
}
int last = DrawPile.Count - 1;
Hand.Add(DrawPile[last]);
DrawPile.RemoveAt(last);
return true;
}
// Tira a carta da posição `index` da mão e a põe no descarte.
public CardDefinition Play(int index)
{
var card = Hand[index];
Hand.RemoveAt(index);
Discard.Add(card);
return card;
}
// Fisher-Yates: troca cada posição com uma sorteada antes dela.
public static void Shuffle(List<CardDefinition> cards, IRandom rng)
{
for (int i = cards.Count - 1; i > 0; i--)
{
int j = rng.Range(0, i);
(cards[i], cards[j]) = (cards[j], cards[i]);
}
}
}
// POR QUE: as contas das cartas (Parte 1 §7.2) num lugar só.
// ESTRATÉGIA: funções puras. Dano de carta é magia: ignora a DEF e vale
// pelo menos 1.
public static class WitchMath
{
// Dano, escudo ou cura de uma carta: ATK × poder × bônus.
public static int Amount(int attack, float power, double bonus = 1.0) =>
System.Math.Max(1, MathUtil.RoundHalfUp(attack * power * bonus));
// A carta pode ser jogada agora?
public static bool CanPlay(CardDefinition card, int mana, bool canSwap, bool partnerAlive)
{
if (card.cost > mana) return false;
if (card.effect == CardEffect.HandOff && !canSwap) return false;
if (card.target == CardTarget.Partner && !partnerAlive) return false;
return true;
}
}
}
🧪 Teste rápido
Os testes destas classes estão na Fase 7. Por enquanto, confira que compila.
Etapa 4C — A mão na tela
// Caminho: Assets/_Project/Scripts/Battle/CardView.cs
using System;
using OldKnight.Data;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace OldKnight.Battle
{
// POR QUE: uma carta na mão da Bruxa (Parte 1 §11): tocar mostra o
// efeito; segurar 0,5 s joga a carta.
// ESTRATÉGIA: mede o tempo entre encostar e soltar o dedo. Se o dedo
// fica até o limite, dispara `Played` uma vez (a barra enche enquanto
// isso); se solta antes, é um toque: `Selected`. Carta sem mana fica
// apagada e não dispara nada.
public class CardView : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IPointerExitHandler
{
[SerializeField] private Image _art;
[SerializeField] private TMP_Text _name;
[SerializeField] private TMP_Text _cost;
[SerializeField] private Image _holdFill;
[SerializeField] private CanvasGroup _group;
[SerializeField] private float _holdSeconds = 0.5f;
private float _pressedAt = -1f;
private bool _fired;
private bool _playable;
public event Action Selected;
public event Action Played;
// Mostra uma carta (ou esconde, com null).
public void Show(CardDefinition card, bool playable)
{
gameObject.SetActive(card != null);
if (card == null) return;
_playable = playable;
_name.text = card.displayName;
_cost.text = card.cost.ToString();
if (_art != null) _art.sprite = card.art;
_group.alpha = playable ? 1f : 0.45f;
_holdFill.fillAmount = 0f;
_pressedAt = -1f;
}
public void OnPointerDown(PointerEventData eventData)
{
_pressedAt = Time.time;
_fired = false;
}
public void OnPointerUp(PointerEventData eventData)
{
if (_pressedAt >= 0f && !_fired) Selected?.Invoke();
_pressedAt = -1f;
_holdFill.fillAmount = 0f;
}
public void OnPointerExit(PointerEventData eventData)
{
_pressedAt = -1f;
_holdFill.fillAmount = 0f;
}
// Enche a barra enquanto o dedo está na carta e joga no limite.
private void Update()
{
if (_pressedAt < 0f || _fired || !_playable) return;
float k = (Time.time - _pressedAt) / _holdSeconds;
_holdFill.fillAmount = Mathf.Clamp01(k);
if (k < 1f) return;
_fired = true;
_holdFill.fillAmount = 0f;
Played?.Invoke();
}
}
}
// Caminho: Assets/_Project/Scripts/Battle/WitchHandView.cs
using System.Collections.Generic;
using OldKnight.Data;
using TMPro;
using UnityEngine;
namespace OldKnight.Battle
{
public enum WitchActionKind
{
None,
Play,
End,
Swap,
Potion,
Flee,
}
// POR QUE: o que a Bruxa escolheu fazer.
public struct WitchAction
{
public WitchActionKind Kind;
public int HandIndex;
}
// POR QUE: o que a tela precisa mostrar neste momento do turno.
public struct WitchHandState
{
public IReadOnlyList<CardDefinition> Hand;
public bool[] Playable;
public int Mana;
public int MaxMana;
public int DrawPile;
public int Discard;
public bool HasPartner;
public bool CanSwap;
public string SwapLabel;
public int Potions;
public bool CanPotion;
public int Maps;
}
// POR QUE: a mão da Bruxa e os botões do turno dela (Parte 1 §11), no
// lugar do menu de comandos do Cavaleiro.
// ESTRATÉGIA: como o CommandMenu do Guerreiro, WaitForAction devolve uma
// promessa que se cumpre com a ação escolhida. Tocar numa carta só mostra
// a descrição; segurar a joga. O turno chama WaitForAction de novo depois
// de cada carta, então a tela é sempre redesenhada com a mão e a mana
// atuais.
public class WitchHandView : MonoBehaviour
{
[SerializeField] private GameObject _root;
[SerializeField] private CardView[] _cards = new CardView[6];
[SerializeField] private TMP_Text _mana;
[SerializeField] private TMP_Text _piles;
[SerializeField] private TMP_Text _info;
[SerializeField] private HoldButton _end;
[SerializeField] private HoldButton _swap;
[SerializeField] private HoldButton _potion;
[SerializeField] private HoldButton _flee;
private AwaitableCompletionSource<WitchAction> _pending;
private IReadOnlyList<CardDefinition> _hand;
// Liga cartas e botões às ações.
private void Awake()
{
for (int i = 0; i < _cards.Length; i++)
{
int index = i;
_cards[i].Selected += () => Describe(index);
_cards[i].Played += () => Complete(new WitchAction { Kind = WitchActionKind.Play, HandIndex = index });
}
_end.Held += () => Complete(new WitchAction { Kind = WitchActionKind.End });
_swap.Held += () => Complete(new WitchAction { Kind = WitchActionKind.Swap });
_potion.Held += () => Complete(new WitchAction { Kind = WitchActionKind.Potion });
_flee.Held += () => Complete(new WitchAction { Kind = WitchActionKind.Flee });
_root.SetActive(false);
}
// Mostra a mão e espera uma ação.
public Awaitable<WitchAction> WaitForAction(WitchHandState s)
{
_hand = s.Hand;
for (int i = 0; i < _cards.Length; i++)
_cards[i].Show(i < s.Hand.Count ? s.Hand[i] : null, i < s.Hand.Count && s.Playable[i]);
_mana.text = $"MANA {s.Mana}/{s.MaxMana}";
_piles.text = $"MONTE {s.DrawPile} DESCARTE {s.Discard}";
_info.text = "Toque numa carta para ver; segure para lançar.";
_swap.gameObject.SetActive(s.HasPartner);
_swap.Interactable = s.CanSwap;
_swap.SetLabel(s.SwapLabel);
_potion.Interactable = s.CanPotion;
_potion.SetLabel($"CURA x{s.Potions}");
_flee.Interactable = s.Maps > 0;
_flee.SetLabel($"FUGIR x{s.Maps}");
_root.SetActive(true);
_pending = new AwaitableCompletionSource<WitchAction>();
return _pending.Awaitable;
}
// Encerra a espera sem ação (a pedrada matou o último monstro).
public void Cancel() => Complete(new WitchAction { Kind = WitchActionKind.None });
// Mostra o efeito da carta tocada.
private void Describe(int index)
{
if (_hand == null || index >= _hand.Count) return;
var card = _hand[index];
_info.text = $"{card.displayName} ({card.cost} de mana): {card.description}";
}
// Cumpre a promessa uma vez só e esconde a mão.
private void Complete(WitchAction action)
{
if (_pending == null) return;
var pending = _pending;
_pending = null;
_root.SetActive(false);
pending.SetResult(action);
}
}
}
Etapa 4D — O turno da Bruxa
// Caminho: Assets/_Project/Scripts/Battle/WitchTurn.cs
using System.Collections.Generic;
using System.Threading;
using OldKnight.Combat;
using OldKnight.Data;
using UnityEngine;
namespace OldKnight.Battle
{
// POR QUE: o turno da Bruxa (Parte 1 §7.1): recebe mana, compra até a
// mão encher e joga cartas enquanto a mana pagar; Encerrar, Trocar, Poção
// e Fugir terminam o turno.
// ESTRATÉGIA: um WitchDeck por masmorra (criado na primeira vaga dela e
// mantido entre as ondas). Cada carta é resolvida por efeito; os alvos
// vêm do tipo de alvo da carta. A pedrada e a escolha de alvo ficam
// ligadas o turno inteiro, como no turno do Cavaleiro. Passo Trocado
// troca e devolve HandOff: o controlador dá a vaga a quem entrou.
public class WitchTurn : MonoBehaviour, IHeroTurnTaker
{
[SerializeField] private BattleStage _stage;
[SerializeField] private WitchHandView _view;
[SerializeField] private TargetSelector _targets;
[SerializeField] private MonoBehaviour _freeAction;
[SerializeField] private PartySwap _swapper;
private BattleContext _owner;
private WitchDeck _deck;
private CancellationToken Token => destroyCancellationToken;
// A vaga da Bruxa.
public async Awaitable<TurnResult> TakeTurn(BattleContext ctx)
{
var witch = ctx.Hero;
if (_owner != ctx)
{
_owner = ctx;
_deck = new WitchDeck(witch.Loadout.Deck, ctx.Rng);
}
witch.GainMana(witch.Loadout.ManaPerTurn);
_deck.DrawUpTo(witch.Loadout.HandSize);
ctx.ShowTarget = true;
_targets.Begin(ctx);
var free = _freeAction as IFreeAction;
free?.Begin(ctx, _view.Cancel);
try
{
while (true)
{
ctx.EnsureTarget();
if (ctx.WaveCleared) return TurnResult.Done;
var action = await _view.WaitForAction(State(ctx));
switch (action.Kind)
{
case WitchActionKind.None:
case WitchActionKind.End:
return TurnResult.Done;
case WitchActionKind.Flee:
ctx.MapsUsed++;
ctx.Log("A Bruxa abriu o mapa e a dupla escapou pelo túnel secreto. O ouro foi mantido.");
return TurnResult.Fled;
case WitchActionKind.Potion:
DrinkPotion(ctx);
await Wait(0.65f);
return TurnResult.Done;
case WitchActionKind.Swap:
await _swapper.Swap(ctx, forced: false);
return TurnResult.Done;
case WitchActionKind.Play:
var card = _deck.Hand[action.HandIndex];
if (!WitchMath.CanPlay(card, witch.Mana, ctx.Party.CanSwap, PartnerAlive(ctx))) continue;
witch.Mana -= card.cost;
_deck.Play(action.HandIndex);
if (await Resolve(ctx, card)) return TurnResult.HandOff;
break;
}
}
}
finally
{
free?.End();
_targets.End();
ctx.ShowTarget = false;
}
}
// O que a mão mostra agora.
private WitchHandState State(BattleContext ctx)
{
var witch = ctx.Hero;
var playable = new bool[_deck.Hand.Count];
for (int i = 0; i < playable.Length; i++)
playable[i] = WitchMath.CanPlay(_deck.Hand[i], witch.Mana, ctx.Party.CanSwap, PartnerAlive(ctx));
var reserve = ctx.Party.Reserve;
return new WitchHandState
{
Hand = _deck.Hand,
Playable = playable,
Mana = witch.Mana,
MaxMana = witch.Loadout.MaxMana,
DrawPile = _deck.DrawPile.Count,
Discard = _deck.Discard.Count,
HasPartner = reserve != null,
CanSwap = ctx.Party.CanSwap,
SwapLabel = reserve != null ? $"TROCAR: {reserve.Name.ToUpperInvariant()}" : "",
Potions = witch.Potions,
CanPotion = witch.Potions > 0 && witch.Hp < witch.MaxHp,
Maps = ctx.MapsAvailable,
};
}
// Resolve uma carta. Devolve true se foi uma troca (HandOff).
private async Awaitable<bool> Resolve(BattleContext ctx, CardDefinition card)
{
var witch = ctx.Hero;
ctx.Log($"{card.displayName}!");
var cast = _stage.HeroView.Strike(Token);
await Wait(0.15f);
switch (card.effect)
{
case CardEffect.Damage:
foreach (var target in Targets(ctx, card))
{
if (target.Dead) continue;
int damage = WitchMath.Amount(witch.Attack, card.power);
var strike = Strikes.Apply(target, damage, 1, damage, 0);
for (int f = 0; f < card.fatigue; f++)
if (CombatMath.AddFatigue(target, 1, ctx.EffectiveLevel, ctx.Balance))
_stage.Float(target, "EXAUSTO!", BattleStage.Cyan, true);
_stage.ShowStrike(target, strike, BattleStage.Purple, card.target == CardTarget.All);
if (strike.Killed) ctx.Slay(target);
if (card.target == CardTarget.Bounce) await Wait(0.12f);
}
if (card.target == CardTarget.All) _stage.Shake();
break;
case CardEffect.Ward:
int ward = WitchMath.Amount(witch.Attack, card.power);
witch.Ward += ward;
_stage.Float(null, $"VÉU +{ward}", BattleStage.Blue, true);
break;
case CardEffect.Heal:
var patient = card.target == CardTarget.Partner ? ctx.Party.Reserve : witch;
int healed = patient.Heal(WitchMath.Amount(witch.Attack, card.power));
if (patient == witch) _stage.Float(null, $"+{healed}", BattleStage.Green, true);
ctx.Log($"{patient.Name} recupera {healed} de HP.");
break;
case CardEffect.HandOff:
await cast;
await _swapper.Swap(ctx, forced: false);
return true;
}
if (card.manaGain > 0) witch.GainMana(card.manaGain);
for (int i = 0; i < card.draw; i++) _deck.Draw();
await cast;
await Awaitable.WaitForSecondsAsync(0.1f, Token);
return false;
}
// Os alvos de uma carta de dano.
private static IEnumerable<Monster> Targets(BattleContext ctx, CardDefinition card)
{
switch (card.target)
{
case CardTarget.All:
foreach (var m in ctx.Alive()) yield return m;
break;
case CardTarget.Bounce:
for (int i = 0; i < card.hits; i++)
{
var alive = ctx.Alive();
if (alive.Count == 0) yield break;
yield return alive[ctx.Rng.Range(0, alive.Count - 1)];
}
break;
default:
ctx.EnsureTarget();
for (int i = 0; i < card.hits && ctx.Target != null; i++) yield return ctx.Target;
break;
}
}
// Poção de Cura da dupla: +40% do HP máximo de quem bebe.
private void DrinkPotion(BattleContext ctx)
{
var witch = ctx.Hero;
witch.Potions--;
int healed = witch.Heal(MathUtil.RoundHalfUp(witch.MaxHp * CombatMath.HealPotionFraction));
_stage.Float(null, $"+{healed}", BattleStage.Green, true);
ctx.Log($"Poção de Cura! +{healed} de HP para a Bruxa.");
}
private static bool PartnerAlive(BattleContext ctx) => ctx.Party.Reserve != null && !ctx.Party.Reserve.Dead;
private Awaitable Wait(float seconds) => Awaitable.WaitForSecondsAsync(seconds, Token);
}
}
BattleTestStarter.cs: a Bruxa de teste precisa de um deck. Depois de montar o Partner, e também se o _heroId for witch, preencha o deck dos loadouts dela:
foreach (var l in new[] { loadout, partner })
if (l != null && l.Hero.heroClass == HeroClass.Witch) l.Deck.AddRange(_database.starterDeck);
(com partner sendo uma variável local que você passa em Partner = partner).
Montar na cena Battle:
- Prefab
CardView: UI → ImageCardView,170 × 240, com Canvas Group e Add Component → Card View. Filhos:Art(Image,150 × 120, no alto),Name(texto24),Cost(texto34num círculo azul no canto superior esquerdo),HoldFill(Image, Filled, Vertical, branco translúcido, esticado). Ligue os campos. ParaPrefabs/. - No Canvas,
WitchHandView(Add Component → Witch Hand View) com o filhoRoot, ancorado na base da tela:Cards: Horizontal Layout Group com 6CardView;- acima das cartas:
Mana(texto34, azul),Piles(24) eInfo(26, quebra de linha); - à direita: um Vertical Layout Group com 4 HoldButtons:
End("ENCERRAR"),Swap,PotioneFlee. Ligue tudo.
- Em
[Systems],WitchTurn(Witch Turn): Stage, View, Targets, Free Action (o mesmoRockThrowerdo Cavaleiro) e Swapper. No Battle Controller, ligue Witch Turn.
🧪 Teste rápido
No BattleTestStarter, ponha Hero Id witch e Partner Id knight. Dê Play:
- A Bruxa começa em campo com 4 cartas e
MANA 2/4. A fila mostra BRUXA duas vezes. - Toque numa carta: a descrição aparece. Segure a Chuva de Brasas: todos os monstros levam dano roxo e a mana cai para 1.
- Jogue Faíscas (custo 0) até a mão esvaziar e segure ENCERRAR. Na vaga seguinte, a mão volta a 4 e a mana sobe 2.
- Faça um parry perfeito com a Bruxa em campo: a mana sobe 1, em vez da Fúria.
- Com uma carta Passo Trocado no deck (arraste o asset
swapsteppara o Starter Deck para testar), a troca acontece e o Cavaleiro age na hora.
✅ Checkpoint da Fase 4
- 13 cartas criadas pelo seeder; deck inicial de 10.
- Mão, monte e descarte funcionam; o descarte volta ao monte quando o monte acaba.
- Cartas de área atingem todos; as de salto sorteiam alvos; Véu absorve dano; Passo Trocado entrega a vaga.
- A Bruxa ganha mana onde o Cavaleiro ganharia Fúria.
- Commit:
feat: a Bruxa com deck e mana.
Problemas comuns
- A mão não aparece e o turno trava: o
WitchTurnnão está ligado no Battle Controller, e o controlador mandou a Bruxa para oWarriorTurn(sem o menu certo). Ligue o campo. - Segurar a carta não faz nada: o Canvas não tem Graphic Raycaster, ou o EventSystem usa o módulo antigo. Use o InputSystemUIInputModule.
- A Bruxa não compra nada: o deck do loadout está vazio. O acampamento (Fase 5) preenche pelo save; no teste direto, é o
BattleTestStarter.
Próxima fase: Fase 5 — Acampamento da dupla.
Parte 8 — Implementação Unity: Fase 5 (Acampamento da dupla)
Objetivo desta fase: o acampamento do guia do Guerreiro passa a cuidar da dupla (Parte 1 §9.1): atributos comprados para cada um, ouro e Souls de um saldo só, gemas só do Cavaleiro, deck da Bruxa salvo, escolha de 1 entre 3 cartas depois de cada vitória e remoção de cartas por ouro.
Conceitos novos:
- Salvar referências por id: o save não guarda o asset da carta, guarda o
iddela ("spark"). Ao montar o loadout, o id vira o asset de novo peloGameDatabase.Card(id). Um id que não existe mais (carta removida do jogo) é ignorado. - "Quem aparece no acampamento entra primeiro": o
activeHerodo save, que no Guerreiro era o herói escolhido, aqui é quem começa em campo. Trocar o personagem mostrado no acampamento troca também quem abre a masmorra.
Etapa 5A — Save e regras
SaveData.cs: acrescente ao SaveData (no fim da classe):
// A Bruxa (Parte 1 §7.3): o deck por id, quantas remoções já foram
// feitas e a escolha de carta pendente depois de uma vitória.
public List<string> deck = new List<string>();
public int cardRemovals;
public List<string> cardChoices = new List<string>();
// Caminho: Assets/_Project/Scripts/Meta/PartyRules.cs
using System.Collections.Generic;
using OldKnight.Combat;
using OldKnight.Data;
namespace OldKnight.Meta
{
// POR QUE: as regras do acampamento que o Guerreiro não tinha (Parte 1
// §7.3 e §9.1): quem é o parceiro, o loadout de cada um com o deck, a
// recompensa de carta e a remoção de cartas.
// ESTRATÉGIA: funções sobre o SaveData, sem tela, como o MetaRules do
// Guerreiro (que continua cuidando de atributos, Tochas, gemas e baú).
// Os números são a proposta do GDD.
public static class PartyRules
{
public const int DeckMin = 8;
public const int DeckMax = 20;
public const int RewardChoices = 3;
public const int RemoveBaseCost = 100;
public const string KnightId = "knight";
public const string WitchId = "witch";
// Save sem deck (jogo novo ou save antigo): recebe o deck inicial.
public static void EnsureDeck(SaveData save, GameDatabase db)
{
if (save.deck.Count > 0) return;
foreach (var card in db.starterDeck) save.deck.Add(card.id);
}
// O outro personagem da dupla.
public static string PartnerId(SaveData save) => save.activeHero == KnightId ? WitchId : KnightId;
// Loadout de um personagem: o do MetaRules mais os números e o deck da Bruxa.
public static HeroLoadout Loadout(GameDatabase db, SaveData save, HeroProgress hero)
{
var loadout = MetaRules.Loadout(db, hero);
var definition = loadout.Hero;
loadout.HandSize = definition.handSize;
loadout.ManaPerTurn = definition.manaPerTurn;
loadout.MaxMana = definition.maxMana;
if (definition.heroClass == HeroClass.Witch)
{
foreach (string id in save.deck)
{
var card = db.Card(id);
if (card != null) loadout.Deck.Add(card);
}
}
return loadout;
}
// Depois de uma vitória: sorteia 3 cartas diferentes do conjunto de recompensas.
public static void RollCardReward(SaveData save, GameDatabase db, IRandom rng)
{
save.cardChoices.Clear();
var pool = new List<CardDefinition>(db.rewardPool);
for (int i = 0; i < RewardChoices && pool.Count > 0; i++)
{
int pick = rng.Range(0, pool.Count - 1);
save.cardChoices.Add(pool[pick].id);
pool.RemoveAt(pick);
}
}
// Pega uma das cartas oferecidas. False se não era uma das opções ou o deck está cheio.
public static bool TakeCard(SaveData save, string id)
{
if (!save.cardChoices.Contains(id) || save.deck.Count >= DeckMax) return false;
save.deck.Add(id);
save.cardChoices.Clear();
return true;
}
// Pula a escolha.
public static void SkipCard(SaveData save) => save.cardChoices.Clear();
// Preço da próxima remoção: 100, 200, 400...
public static int RemoveCost(SaveData save) => RemoveBaseCost << System.Math.Min(save.cardRemovals, 20);
// Remove a carta da posição `index` do deck, se houver ouro e o deck passar do mínimo.
public static bool TryRemoveCard(SaveData save, int index)
{
int cost = RemoveCost(save);
if (index < 0 || index >= save.deck.Count) return false;
if (save.deck.Count <= DeckMin || save.gold < cost) return false;
save.gold -= cost;
save.deck.RemoveAt(index);
save.cardRemovals++;
return true;
}
}
}
GameSession.cs, três mudanças:
-
No
Awake, logo depois deSave = SaveService.Load(_database);:PartyRules.EnsureDeck(Save, _database); -
Troque o
EnterDungeoninteiro:// Entra na masmorra atual: quem aparece no acampamento começa em campo,// o outro na reserva. Os pergaminhos de gema são do Cavaleiro.public async void EnterDungeon(){var b = _database.balance;if (!MetaRules.TrySpendTorch(Save, DateTime.UtcNow, b)) return;Save.entries++;Persist();if (Save.entries % b.interstitialEvery == 0) await Ads.Current.ShowInterstitial();var leader = ActiveHero;var partner = MetaRules.Progress(Save, PartyRules.PartnerId(Save));var knight = MetaRules.Progress(Save, PartyRules.KnightId);PendingRequest = new DungeonRequest{Loadout = PartyRules.Loadout(_database, Save, leader),Partner = partner != null ? PartyRules.Loadout(_database, Save, partner) : null,Level = Save.dungeonLevel,NgPlus = Save.ngPlus,Color = Save.dungeonColor,Souls = Save.souls,Maps = Save.maps,GemsUnlocked = new List<GemId>(knight.gemsUnlocked),};SceneManager.LoadScene("Battle");} -
Troque o
FinishDungeoninteiro:// Aplica o fim da masmorra (gemas no Cavaleiro), sorteia a carta da// vitória e volta ao acampamento.public void FinishDungeon(DungeonOutcome outcome){var knight = MetaRules.Progress(Save, PartyRules.KnightId);MetaRules.ApplyOutcome(Save, knight, outcome, _database.balance, _rng);if (outcome.End.Kind == DungeonOutcomeKind.Victory) PartyRules.RollCardReward(Save, _database, _rng);Persist();PendingRequest = null;SceneManager.LoadScene("Camp");}
MarketScreen.cs e GemRow.cs: as gemas são só do Cavaleiro. Onde esses scripts usam GameSession.Instance.ActiveHero, troque por MetaRules.Progress(GameSession.Instance.Save, PartyRules.KnightId).
🧪 Teste rápido
Apague o save antigo (Edit → Clear All PlayerPrefs). Dê Play na cena Camp e entre na masmorra: a Bruxa da reserva tem o deck inicial de 10 cartas. Vença (ou use o AutoPlayer com Skill 1): de volta ao acampamento, o SaveData (no Inspector do GameSession, em modo Debug) tem 3 ids em Card Choices.
Etapa 5B — As telas
// Caminho: Assets/_Project/Scripts/Meta/CardRewardPanel.cs
using System;
using OldKnight.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace OldKnight.Meta
{
// POR QUE: a escolha de 1 entre 3 cartas depois de uma vitória (Parte 1
// §7.3), mostrada no acampamento.
// ESTRATÉGIA: lê as opções pendentes no save; cada botão chama
// PartyRules.TakeCard. Pular limpa as opções. Fechar avisa quem abriu,
// para o acampamento se redesenhar.
public class CardRewardPanel : MonoBehaviour
{
[SerializeField] private GameObject _root;
[SerializeField] private Button[] _choices = new Button[3];
[SerializeField] private TMP_Text[] _labels = new TMP_Text[3];
[SerializeField] private Button _skip;
[SerializeField] private TMP_Text _note;
private Action _onClose;
private void Awake()
{
_root.SetActive(false);
for (int i = 0; i < _choices.Length; i++)
{
int index = i;
_choices[i].onClick.AddListener(() => Take(index));
}
_skip.onClick.AddListener(Skip);
}
// Abre o painel se houver escolha pendente.
public void Open(Action onClose)
{
var session = GameSession.Instance;
var save = session.Save;
if (save.cardChoices.Count == 0) return;
_onClose = onClose;
for (int i = 0; i < _choices.Length; i++)
{
bool used = i < save.cardChoices.Count;
_choices[i].gameObject.SetActive(used);
if (!used) continue;
var card = session.Database.Card(save.cardChoices[i]);
_labels[i].text = card != null ? $"{card.displayName}\n{card.cost} de mana\n{card.description}" : "?";
}
_note.text = save.deck.Count >= PartyRules.DeckMax
? $"O deck está cheio ({PartyRules.DeckMax}). Remova uma carta no Deck antes."
: $"Escolha uma carta para o deck da Bruxa ({save.deck.Count} cartas).";
_root.SetActive(true);
}
// Pega a carta da posição `index`.
private void Take(int index)
{
var session = GameSession.Instance;
var save = session.Save;
if (index >= save.cardChoices.Count || !PartyRules.TakeCard(save, save.cardChoices[index])) return;
session.Persist();
Close();
}
// Pula a escolha.
private void Skip()
{
PartyRules.SkipCard(GameSession.Instance.Save);
GameSession.Instance.Persist();
Close();
}
private void Close()
{
_root.SetActive(false);
_onClose?.Invoke();
}
}
}
// Caminho: Assets/_Project/Scripts/Meta/DeckScreen.cs
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace OldKnight.Meta
{
// POR QUE: a tela de Deck da Bruxa (Parte 1 §7.3 e §11): ver as cartas
// e remover uma por ouro.
// ESTRATÉGIA: uma linha por carta, criada a partir de um prefab (um
// Button com um texto filho). Tocar numa linha remove a carta pelo
// preço atual; a lista é refeita depois de cada remoção.
public class DeckScreen : MonoBehaviour
{
[SerializeField] private GameObject _root;
[SerializeField] private Transform _list;
[SerializeField] private Button _rowPrefab;
[SerializeField] private TMP_Text _header;
[SerializeField] private Button _close;
private readonly List<Button> _rows = new List<Button>();
private Action _onClose;
private void Awake()
{
_root.SetActive(false);
_close.onClick.AddListener(() =>
{
_root.SetActive(false);
_onClose?.Invoke();
});
}
// Abre a tela.
public void Open(Action onClose)
{
_onClose = onClose;
_root.SetActive(true);
Rebuild();
}
// Refaz a lista com o deck e o preço atuais.
private void Rebuild()
{
foreach (var row in _rows) Destroy(row.gameObject);
_rows.Clear();
var session = GameSession.Instance;
var save = session.Save;
int cost = PartyRules.RemoveCost(save);
bool canRemove = save.deck.Count > PartyRules.DeckMin;
_header.text = $"DECK DA BRUXA: {save.deck.Count} cartas (mín. {PartyRules.DeckMin}, máx. {PartyRules.DeckMax})\n" +
(canRemove ? $"Toque numa carta para remover por {cost} de ouro (você tem {save.gold})."
: "O deck está no mínimo.");
for (int i = 0; i < save.deck.Count; i++)
{
int index = i;
var card = session.Database.Card(save.deck[i]);
var row = Instantiate(_rowPrefab, _list);
row.GetComponentInChildren<TMP_Text>().text = card != null ? $"{card.displayName} ({card.cost}) {card.description}" : save.deck[i];
row.interactable = canRemove && save.gold >= cost;
row.onClick.AddListener(() => Remove(index));
_rows.Add(row);
}
}
// Remove uma carta e redesenha.
private void Remove(int index)
{
var session = GameSession.Instance;
if (!PartyRules.TryRemoveCard(session.Save, index)) return;
session.Persist();
Rebuild();
}
}
}
CampScreen.cs, quatro mudanças:
-
Campos novos:
[SerializeField] private Button _switchHero;[SerializeField] private Button _deck;[SerializeField] private DeckScreen _deckScreen;[SerializeField] private CardRewardPanel _cardReward; -
No fim do
Start:_switchHero.onClick.AddListener(SwitchHero);_deck.onClick.AddListener(() => _deckScreen.Open(Refresh));_cardReward.Open(Refresh); // só aparece se a última masmorra deixou uma escolha -
Um método novo:
// Mostra o outro personagem: ele passa a ser quem entra primeiro em campo.private void SwitchHero(){var session = GameSession.Instance;session.Save.activeHero = PartyRules.PartnerId(session.Save);session.Persist();Refresh();} -
No
Refresh, depois de preencher o nome do herói, mostre quem entra primeiro e esconda o Deck no Cavaleiro:_heroName.text += " (entra primeiro)";_deck.gameObject.SetActive(session.Save.activeHero == PartyRules.WitchId);(use a variável da sessão que o
Refreshjá tem; se ele a chama de outro nome, ajuste).
Montar na cena Camp:
- Ao lado do nome do herói, um Button
SwitchHero("TROCAR PERSONAGEM"). - Um Button
Deck("DECK") perto do Mercado. DeckScreen: painel em tela cheia com Scroll View (o Content com Vertical Layout Group e Content Size Fitter em Preferred Size é a List),Headerno alto eClose("VOLTAR"). PrefabDeckRow: UI → Button - TextMeshPro,900 × 90, texto à esquerda28.CardRewardPanel: painel centralizado com 3 botões grandes (300 × 420, texto28), oNoteacima eSkip("PULAR") abaixo.- Ligue os quatro campos novos no Camp Screen.
🧪 Teste rápido
- TROCAR PERSONAGEM alterna entre o Cavaleiro e a Bruxa: atributos e preços são de cada um, o ouro é o mesmo. O botão DECK só aparece com a Bruxa.
- Compre Ataque para a Bruxa e entre: as cartas dela batem mais forte (o dano usa o ATK dela).
- Vença uma masmorra: ao voltar, o painel oferece 3 cartas. Pegue uma: o deck vai a 11.
- No DECK, remova uma carta: o ouro cai 100 e a próxima remoção custa 200. Com 8 cartas, as linhas ficam apagadas.
✅ Checkpoint da Fase 5
- Progressão por personagem, saldo único, gemas só no Cavaleiro.
- Deck salvo por id; escolha de carta depois da vitória; remoção com preço dobrando.
- Quem aparece no acampamento começa em campo.
- Commit:
feat: acampamento da dupla e deck.
Próxima fase: Fase 6 — Extras do app.
Parte 9 — Implementação Unity: Fase 6 (Extras do app)
Objetivo desta fase: os dois sistemas do app que o Guerreiro original não tinha (Parte 1 §9.3 e §9.4). A árvore de Souls substitui o atributo Habilidade: libera os especiais do Cavaleiro e melhora a Bruxa. As missões diárias dão ouro.
Conceitos novos:
- Tabela no código em vez de asset: a árvore tem 8 nós fixos. Uma tabela
static readonlyem C# é mais simples de ler e testar do que 8 assets, e nada nela precisa de arte. - Virada do dia local: as missões zeram quando a data local muda (
DateTime.Now.ToString("yyyy-MM-dd")), como o Baú do Dia do Guerreiro.
Etapa 6A — Árvore de Souls
SaveData.cs: acrescente a classe e o campo:
// POR QUE: o nível de um nó da árvore de um personagem.
[Serializable]
public class TreeLevel
{
public string heroId;
public string node;
public int level;
}
// Árvore de Souls (Parte 1 §9.3).
public List<TreeLevel> tree = new List<TreeLevel>();
HeroRun.cs: no HeroLoadout, os bônus da árvore (com valores neutros por padrão):
// Árvore de Souls (Parte 1 §9.3). Valores neutros fora dela.
public int TreeSkills = -1; // especiais liberados; -1 = usa a Habilidade do Guerreiro
public double FuryBonus = 1.0;
public int ComboMaxHits = 4;
public double BladeBonus = 1.0;
public double AreaBonus = 1.0;
E troque o SkillsUnlocked:
// Quantos especiais estão liberados: pela árvore, se houver; senão, pela Habilidade.
public int SkillsUnlocked(int step) => UnlockAllSkills ? 99 : TreeSkills >= 0 ? TreeSkills : SkillLevel / step;
// Caminho: Assets/_Project/Scripts/Meta/SkillTree.cs
using System.Collections.Generic;
using OldKnight.Combat;
namespace OldKnight.Meta
{
// POR QUE: um nó da árvore de Souls.
public sealed class TreeNode
{
public string Id;
public string Name;
public int Cost;
public string Requires;
public string Description;
}
// POR QUE: a árvore de Souls da Parte 1 §9.3: a do Cavaleiro é a do
// Guerreiro do app; a da Bruxa é a proposta do GDD.
// ESTRATÉGIA: tabela fixa por herói + regras puras de custo e compra
// sobre o SaveData. `Apply` traduz os níveis em números do HeroLoadout;
// é o único lugar que sabe o que cada nó faz.
public static class SkillTree
{
public const int MaxLevel = 3;
public static readonly IReadOnlyList<TreeNode> Knight = new List<TreeNode>
{
new TreeNode { Id = "fury", Name = "Fúria", Cost = 1, Description = "Libera o especial. +10% de dano por nível" },
new TreeNode { Id = "combo", Name = "Combo", Cost = 2, Requires = "fury", Description = "Libera. Até 1 + nível golpes" },
new TreeNode { Id = "blade", Name = "Lâmina Invisível", Cost = 3, Requires = "combo", Description = "Libera. +15% de dano por nível" },
new TreeNode { Id = "seq", Name = "Sequência Mortal", Cost = 4, Requires = "blade", Description = "Libera. Janela maior: Em aberto" },
};
public static readonly IReadOnlyList<TreeNode> Witch = new List<TreeNode>
{
new TreeNode { Id = "font", Name = "Fonte", Cost = 1, Description = "+1 de mana máxima por nível" },
new TreeNode { Id = "hand", Name = "Mão Leve", Cost = 2, Requires = "font", Description = "+1 carta na mão nos níveis 2 e 3" },
new TreeNode { Id = "spark", Name = "Faísca Viva", Cost = 3, Requires = "hand", Description = "Cartas de área +10% por nível" },
new TreeNode { Id = "coven", Name = "Coven", Cost = 4, Requires = "spark", Description = "+1 de mana no início da masmorra por nível" },
};
// Os nós de um personagem.
public static IReadOnlyList<TreeNode> For(string heroId) => heroId == PartyRules.WitchId ? Witch : Knight;
// Nível atual de um nó.
public static int Level(SaveData save, string heroId, string node)
{
var entry = save.tree.Find(t => t.heroId == heroId && t.node == node);
return entry != null ? entry.level : 0;
}
// Custo do próximo nível: custo do nó × (nível atual + 1).
public static int Cost(SaveData save, string heroId, TreeNode node) => node.Cost * (Level(save, heroId, node.Id) + 1);
// Dá para comprar o próximo nível?
public static bool CanBuy(SaveData save, string heroId, TreeNode node)
{
if (Level(save, heroId, node.Id) >= MaxLevel) return false;
if (node.Requires != null && Level(save, heroId, node.Requires) == 0) return false;
return save.souls >= Cost(save, heroId, node);
}
// Compra o próximo nível com Souls.
public static bool TryBuy(SaveData save, string heroId, TreeNode node)
{
if (!CanBuy(save, heroId, node)) return false;
save.souls -= Cost(save, heroId, node);
var entry = save.tree.Find(t => t.heroId == heroId && t.node == node.Id);
if (entry == null) save.tree.Add(entry = new TreeLevel { heroId = heroId, node = node.Id });
entry.level++;
return true;
}
// Aplica a árvore ao loadout de um personagem.
public static void Apply(SaveData save, string heroId, HeroLoadout loadout)
{
int Lv(string node) => Level(save, heroId, node);
if (heroId == PartyRules.WitchId)
{
loadout.MaxMana += Lv("font");
loadout.HandSize += System.Math.Max(0, Lv("hand") - 1);
loadout.AreaBonus = 1.0 + 0.1 * Lv("spark");
loadout.StartMana = Lv("coven");
return;
}
// A cadeia é linear (fury → combo → blade → seq), então contar os
// nós comprados dá os especiais na ordem do guia do Guerreiro.
int unlocked = 0;
foreach (var node in Knight) if (Lv(node.Id) > 0) unlocked++;
loadout.TreeSkills = unlocked;
loadout.FuryBonus = 1.0 + 0.1 * Lv("fury");
loadout.ComboMaxHits = 1 + Lv("combo");
loadout.BladeBonus = 1.0 + 0.15 * Lv("blade");
}
}
}
PartyRules.Loadout: antes do return loadout;, aplique a árvore:
SkillTree.Apply(save, hero.heroId, loadout);
WarriorTurn.cs: os bônus da árvore (Parte 1 §9.3, proposta). Três trocas:
-
Em
FurySpecial, depois dedouble mult = tier.Mult;:mult *= hero.Loadout.FuryBonus; -
Em
Combo, troqueint hits = CombatMath.ComboHits(hero.Fury);por:int hits = System.Math.Min(CombatMath.ComboHits(hero.Fury), hero.Loadout.ComboMaxHits); -
Em
InvisibleBlade, troque as duas ocorrências deCombatMath.BladeMult(noHeroStrikee noStrikes.Apply) porCombatMath.BladeMult * hero.Loadout.BladeBonus.
WitchTurn.cs: o bônus de área. No Resolve, troque a linha do dano:
int damage = WitchMath.Amount(witch.Attack, card.power,
card.target == CardTarget.All ? witch.Loadout.AreaBonus : 1.0);
CampScreen.cs: o atributo Habilidade sai (a árvore o substitui). Esconda o quarto botão de atributo: no Start, _upgradeButtons[3].gameObject.SetActive(false);.
// Caminho: Assets/_Project/Scripts/Meta/SkillTreeScreen.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace OldKnight.Meta
{
// POR QUE: a tela da árvore de Souls do personagem mostrado no
// acampamento (Parte 1 §9.3).
// ESTRATÉGIA: 4 botões fixos, um por nó, na ordem da cadeia. Cada um
// mostra nome, nível, efeito e custo; trancado, diz o que falta.
public class SkillTreeScreen : MonoBehaviour
{
[SerializeField] private GameObject _root;
[SerializeField] private Button[] _nodes = new Button[4];
[SerializeField] private TMP_Text[] _labels = new TMP_Text[4];
[SerializeField] private TMP_Text _souls;
[SerializeField] private Button _close;
private Action _onClose;
private void Awake()
{
_root.SetActive(false);
for (int i = 0; i < _nodes.Length; i++)
{
int index = i;
_nodes[i].onClick.AddListener(() => Buy(index));
}
_close.onClick.AddListener(() =>
{
_root.SetActive(false);
_onClose?.Invoke();
});
}
// Abre a árvore de quem está no acampamento.
public void Open(Action onClose)
{
_onClose = onClose;
_root.SetActive(true);
Refresh();
}
// Compra o próximo nível de um nó.
private void Buy(int index)
{
var session = GameSession.Instance;
string heroId = session.Save.activeHero;
if (!SkillTree.TryBuy(session.Save, heroId, SkillTree.For(heroId)[index])) return;
session.Persist();
Refresh();
}
// Redesenha níveis, custos e trancas.
private void Refresh()
{
var save = GameSession.Instance.Save;
string heroId = save.activeHero;
var nodes = SkillTree.For(heroId);
_souls.text = $"SOULS {save.souls}";
for (int i = 0; i < _nodes.Length; i++)
{
var node = nodes[i];
int level = SkillTree.Level(save, heroId, node.Id);
bool locked = node.Requires != null && SkillTree.Level(save, heroId, node.Requires) == 0;
string price = level >= SkillTree.MaxLevel ? "MÁXIMO"
: locked ? "trancado" : $"{SkillTree.Cost(save, heroId, node)} Souls";
_labels[i].text = $"{node.Name} Nv.{level}/{SkillTree.MaxLevel}\n{node.Description}\n{price}";
_nodes[i].interactable = SkillTree.CanBuy(save, heroId, node);
}
}
}
}
Montar: na cena Camp, um Button Tree ("ÁRVORE") e o painel SkillTreeScreen (4 botões em coluna, Souls no alto, Close). No CampScreen, um campo [SerializeField] private SkillTreeScreen _treeScreen; e [SerializeField] private Button _tree;, e no Start: _tree.onClick.AddListener(() => _treeScreen.Open(Refresh));.
🧪 Teste rápido
Com Souls no save (ganhe uma masmorra, ou edite o save no modo Debug do Inspector):
- No Cavaleiro, compre Fúria: na masmorra, o botão ESPECIAL mostra só FÚRIA. Compre Combo e ele aparece também.
- Na Bruxa, Fonte Nv.1 muda a mana para
x/5; Coven Nv.1 faz a primeira vaga começar com 1 de mana a mais.
Etapa 6B — Missões diárias
SaveData.cs: acrescente:
// POR QUE: o progresso de uma missão diária no dia.
[Serializable]
public class QuestProgress
{
public string id;
public int value;
public bool claimed;
}
// Missões diárias (Parte 1 §9.4): o dia e o progresso de cada uma.
public string questDay = "";
public List<QuestProgress> quests = new List<QuestProgress>();
BattleContext.cs: as missões precisam de dois números que o resumo da masmorra não tinha. No BattleEnd, acrescente public int CleanKills; e public bool BossKilled;. No BattleContext, acrescente public int CleanKills;.
BattleController.cs: conte os monstros mortos em ondas sem dano.
-
No
RunDungeon, logo antes devar outcome = await FightWave(token);:int hpBefore = PartyHp(); -
Logo depois de
if (outcome.HasValue) return End(outcome.Value);:// Missão "Intocável": monstros de uma onda vencida sem nenhum dano na dupla.if (PartyHp() >= hpBefore) Context.CleanKills += Context.Monsters.Count; -
Um método novo:
// HP somado da dupla.private int PartyHp(){int sum = 0;foreach (var member in Context.Party.Members) sum += member.Hp;return sum;} -
No
End, acrescenteCleanKills = Context.CleanKills,eBossKilled = kind == DungeonOutcomeKind.Victory,.
Uma Poção de Cura no meio da onda pode esconder um dano. Está aceito: é uma aproximação, e no app a missão também é contada por onda.
// Caminho: Assets/_Project/Scripts/Meta/QuestRules.cs
using System.Collections.Generic;
using OldKnight.Battle;
namespace OldKnight.Meta
{
// POR QUE: uma missão diária.
public sealed class QuestDefinition
{
public string Id;
public string Name;
public string Description;
public int Goal;
public int Gold;
}
// POR QUE: as missões diárias da Parte 1 §9.4 (as 5 do app e a da dupla).
// ESTRATÉGIA: tabela fixa + regras puras sobre o SaveData. `Today` zera
// tudo quando a data muda. `Record` soma o resultado de uma masmorra em
// cada missão; `Claim` paga o ouro uma vez.
public static class QuestRules
{
public static readonly IReadOnlyList<QuestDefinition> All = new List<QuestDefinition>
{
new QuestDefinition { Id = "bosses", Name = "Caçador de Chefões", Description = "Vença 5 chefões", Goal = 5, Gold = 600 },
new QuestDefinition { Id = "slay", Name = "Intocável", Description = "Derrote 30 monstros sem levar dano na onda", Goal = 30, Gold = 800 },
new QuestDefinition { Id = "gold", Name = "Cofre Cheio", Description = "Conquiste 10.000 moedas", Goal = 10000, Gold = 1000 },
new QuestDefinition { Id = "souls", Name = "Ceifador de Almas", Description = "Conquiste 10 Souls", Goal = 10, Gold = 1200 },
new QuestDefinition { Id = "perfect", Name = "Impecável", Description = "Vença 3 masmorras sem sofrer dano", Goal = 3, Gold = 1500 },
new QuestDefinition { Id = "swaps", Name = "Troca Perfeita", Description = "Troque 10 vezes e vença a masmorra", Goal = 1, Gold = 800 },
};
// Garante que o progresso é de hoje (zera na virada do dia).
public static void Today(SaveData save, string today)
{
if (save.questDay == today) return;
save.questDay = today;
save.quests.Clear();
foreach (var quest in All) save.quests.Add(new QuestProgress { id = quest.Id });
}
// Soma uma masmorra terminada. `goldEarned` e `soulsEarned` vêm do acampamento.
public static void Record(SaveData save, string today, BattleEnd end, int goldEarned, int soulsEarned)
{
Today(save, today);
bool victory = end.Kind == DungeonOutcomeKind.Victory;
Add(save, "bosses", end.BossKilled ? 1 : 0);
Add(save, "slay", end.CleanKills);
Add(save, "gold", goldEarned);
Add(save, "souls", soulsEarned);
Add(save, "perfect", victory && end.Flawless ? 1 : 0);
Add(save, "swaps", victory && end.Swaps >= 10 ? 1 : 0);
}
// O progresso de uma missão (nunca passa da meta).
public static int Progress(SaveData save, QuestDefinition quest)
{
var p = save.quests.Find(q => q.id == quest.Id);
return p == null ? 0 : System.Math.Min(quest.Goal, p.value);
}
// Já foi paga hoje?
public static bool Claimed(SaveData save, QuestDefinition quest)
{
var p = save.quests.Find(q => q.id == quest.Id);
return p != null && p.claimed;
}
// Paga a missão cumprida. Devolve o ouro pago (0 se não deu).
public static int Claim(SaveData save, string today, QuestDefinition quest)
{
Today(save, today);
var p = save.quests.Find(q => q.id == quest.Id);
if (p == null || p.claimed || p.value < quest.Goal) return 0;
p.claimed = true;
save.gold += quest.Gold;
return quest.Gold;
}
// Soma `amount` numa missão.
private static void Add(SaveData save, string id, int amount)
{
if (amount <= 0) return;
var p = save.quests.Find(q => q.id == id);
if (p != null) p.value += amount;
}
}
}
GameSession.FinishDungeon: registre a masmorra nas missões antes do ApplyOutcome mudar o ouro e as Souls. Troque o começo do método:
var knight = MetaRules.Progress(Save, PartyRules.KnightId);
int goldBefore = Save.gold;
int soulsBefore = Save.souls;
MetaRules.ApplyOutcome(Save, knight, outcome, _database.balance, _rng);
QuestRules.Record(Save, DateTime.Now.ToString("yyyy-MM-dd"), outcome.End,
System.Math.Max(0, Save.gold - goldBefore), System.Math.Max(0, Save.souls - soulsBefore + outcome.End.SoulsUsed));
(As Souls gastas para reviver são somadas de volta, para a missão contar só as ganhas.)
// Caminho: Assets/_Project/Scripts/Meta/QuestScreen.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace OldKnight.Meta
{
// POR QUE: a lista de missões diárias no acampamento, com o botão de
// resgatar (Parte 1 §9.4).
// ESTRATÉGIA: 6 linhas fixas, uma por missão da tabela. O botão de cada
// uma só acende com a meta cumprida e ainda não paga.
public class QuestScreen : MonoBehaviour
{
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text[] _labels = new TMP_Text[6];
[SerializeField] private Button[] _claims = new Button[6];
[SerializeField] private Button _close;
private Action _onClose;
private static string Today => DateTime.Now.ToString("yyyy-MM-dd");
private void Awake()
{
_root.SetActive(false);
for (int i = 0; i < _claims.Length; i++)
{
int index = i;
_claims[i].onClick.AddListener(() => Claim(index));
}
_close.onClick.AddListener(() =>
{
_root.SetActive(false);
_onClose?.Invoke();
});
}
// Abre a lista.
public void Open(Action onClose)
{
_onClose = onClose;
QuestRules.Today(GameSession.Instance.Save, Today);
_root.SetActive(true);
Refresh();
}
// Resgata uma missão.
private void Claim(int index)
{
var session = GameSession.Instance;
if (QuestRules.Claim(session.Save, Today, QuestRules.All[index]) <= 0) return;
session.Persist();
Refresh();
}
// Redesenha progresso e botões.
private void Refresh()
{
var save = GameSession.Instance.Save;
for (int i = 0; i < _labels.Length && i < QuestRules.All.Count; i++)
{
var quest = QuestRules.All[i];
int progress = QuestRules.Progress(save, quest);
bool claimed = QuestRules.Claimed(save, quest);
_labels[i].text = $"{quest.Name}: {quest.Description}\n{progress}/{quest.Goal} · {quest.Gold} de ouro";
_claims[i].interactable = progress >= quest.Goal && !claimed;
_claims[i].GetComponentInChildren<TMP_Text>().text = claimed ? "PAGO" : "RESGATAR";
}
}
}
}
Montar: na cena Camp, um Button Quests ("MISSÕES") e o painel QuestScreen (6 linhas com texto e botão "RESGATAR", e Close). No CampScreen, campos _questScreen e _quests e, no Start, _quests.onClick.AddListener(() => _questScreen.Open(Refresh));.
🧪 Teste rápido
Vença uma masmorra e abra MISSÕES: "Caçador de Chefões" está em 1/5, e "Cofre Cheio" somou o ouro da vitória. Troque a data do computador para o dia seguinte e abra de novo: tudo zerou.
✅ Checkpoint da Fase 6
- Árvore de Souls dos dois, com custo crescente e cadeia de pré-requisitos; Habilidade escondida.
- Bônus de Fúria, Combo, Lâmina e área aplicados; mana e mão da Bruxa crescendo com a árvore.
- Missões diárias com progresso, resgate e virada do dia.
- Commit:
feat: árvore de Souls e missões diárias.
Problemas comuns
- O ESPECIAL mostra os quatro especiais sem ter comprado nada: o
BattleTestStarterusa Skill Level20, que vale quandoTreeSkillsé-1(sem árvore). Pelo acampamento, a árvore manda. - A missão "Ceifador de Almas" não anda: a vitória dá Souls dentro do
ApplyOutcome; oRecordprecisa vir depois dele, como no código acima.
Próxima fase: Fase 7 — Testes automáticos.
Parte 10 — Implementação Unity: Fase 7 (Testes automáticos)
Objetivo desta fase: as regras novas (dupla, fila, deck, mana, acampamento, árvore e missões) cobertas por testes que rodam sem cena, ao lado dos testes herdados do Guerreiro.
Os assemblies e o ScriptedRandom são os da Fase 8 do Guerreiro (com os nomes trocados na Etapa 1A). Com a fila vazia, o ScriptedRandom devolve sempre o mínimo: o embaralhar do WitchDeck fica previsível, e as rolagens da fila ficam todas em +0.
// Caminho: Assets/_Project/Tests/PartyTests.cs
using System.Collections.Generic;
using System.Linq;
using OldKnight.Combat;
using OldKnight.Data;
using NUnit.Framework;
using static OldKnight.Tests.TestData;
namespace OldKnight.Tests
{
// POR QUE: a dupla (Parte 1 §4): troca, reserva caída, poções
// compartilhadas, ouro somado, mana no lugar da Fúria e a fila de cada um.
public class PartyTests
{
private static HeroRun Knight() => new HeroRun(HeroLoadout.Fresh(Db.Hero("knight")), B);
private static HeroRun Witch() => new HeroRun(HeroLoadout.Fresh(Db.Hero("witch")), B);
[Test]
public void TrocaPoeOOutroEmCampoEZeraAGuardaDeQuemEntra()
{
var knight = Knight();
var witch = Witch();
witch.Stagger = 2;
var party = new PartyRun(knight, witch);
Assert.AreSame(knight, party.Active);
Assert.IsTrue(party.Swap());
Assert.AreSame(witch, party.Active);
Assert.AreEqual(0, witch.Stagger);
Assert.AreEqual(1, party.Swaps);
}
[Test]
public void NaoTrocaComAReservaCaida()
{
var witch = Witch();
witch.Hp = 0;
var party = new PartyRun(Knight(), witch);
Assert.IsFalse(party.CanSwap);
Assert.IsFalse(party.Swap());
Assert.IsFalse(party.AllDead);
}
[Test]
public void PocoesSaoDaDupla()
{
var knight = Knight();
var witch = Witch();
var party = new PartyRun(knight, witch);
knight.Potions--;
Assert.AreEqual(knight.Potions, witch.Potions);
Assert.AreEqual(B.potionsPerRun - 1, party.Reserve.Potions);
}
[Test]
public void OuroSomaEPerfeitaPedeOsDois()
{
var knight = Knight();
var witch = Witch();
var party = new PartyRun(knight, witch);
knight.RunGold = 10;
witch.RunGold = 5;
Assert.AreEqual(15, party.RunGold);
witch.TakeDamage(1);
Assert.IsFalse(party.Flawless);
}
[Test]
public void NaBruxaFuriaViraMana()
{
var witch = Witch();
for (int i = 0; i < 10; i++) witch.GainFury();
Assert.AreEqual(0, witch.Fury);
Assert.AreEqual(witch.Loadout.MaxMana, witch.Mana);
}
[Test]
public void BruxaTemDuasVagasECavaleiroUma()
{
var none = new List<Monster>();
var witchQueue = Initiative.Roll(none, Db.Hero("witch"), new ScriptedRandom(), B);
var knightQueue = Initiative.Roll(none, Db.Hero("knight"), new ScriptedRandom(), B);
Assert.AreEqual(2, witchQueue.Count(s => s.IsHero));
Assert.AreEqual(1, knightQueue.Count(s => s.IsHero));
}
[Test]
public void CavaleiroLentoAgeDepoisDoServo()
{
var monsters = new List<Monster> { Monster("pawn") }; // velocidade 12
var queue = Initiative.Roll(monsters, Db.Hero("knight"), new ScriptedRandom(), B);
Assert.IsFalse(queue[0].IsHero);
Assert.IsTrue(queue[1].IsHero);
}
}
}
// Caminho: Assets/_Project/Tests/WitchDeckTests.cs
using System.Collections.Generic;
using System.Linq;
using OldKnight.Combat;
using OldKnight.Data;
using OldKnight.Meta;
using NUnit.Framework;
using static OldKnight.Tests.TestData;
namespace OldKnight.Tests
{
// POR QUE: o deck e a mana da Bruxa (Parte 1 §7) e as regras do
// acampamento da dupla, da árvore e das missões (§7.3, §9.3, §9.4).
public class WitchDeckTests
{
private static List<CardDefinition> Starter => Db.starterDeck;
[Test]
public void CompraAteAMaoEncher()
{
var deck = new WitchDeck(Starter, new ScriptedRandom());
Assert.AreEqual(4, deck.DrawUpTo(4));
Assert.AreEqual(4, deck.Hand.Count);
Assert.AreEqual(Starter.Count - 4, deck.DrawPile.Count);
}
[Test]
public void MonteVazioEmbaralhaODescarte()
{
var three = Starter.Take(3).ToList();
var deck = new WitchDeck(three, new ScriptedRandom());
deck.DrawUpTo(3);
for (int i = 0; i < 3; i++) deck.Play(0);
Assert.AreEqual(3, deck.Discard.Count);
Assert.IsTrue(deck.Draw());
Assert.AreEqual(0, deck.Discard.Count);
Assert.AreEqual(2, deck.DrawPile.Count);
}
[Test]
public void SemCartasNaoCompra()
{
var deck = new WitchDeck(new List<CardDefinition>(), new ScriptedRandom());
Assert.IsFalse(deck.Draw());
}
[Test]
public void ManaETrocaDecidemSeACartaSai()
{
var embers = Db.Card("embers"); // custo 1
var swapstep = Db.Card("swapstep"); // troca
Assert.IsFalse(WitchMath.CanPlay(embers, 0, true, true));
Assert.IsTrue(WitchMath.CanPlay(embers, 1, true, true));
Assert.IsFalse(WitchMath.CanPlay(swapstep, 4, false, false));
}
[Test]
public void DanoDeCartaIgnoraDefEValePeloMenos1()
{
Assert.AreEqual(4, WitchMath.Amount(8, 0.5f));
Assert.AreEqual(1, WitchMath.Amount(1, 0.1f));
Assert.AreEqual(4, WitchMath.Amount(8, 0.5f, 1.1)); // 4,4 → 4
}
[Test]
public void RemoverCartaDobraOPrecoEParaNoMinimo()
{
var save = MetaRules.NewGame(Db, new ScriptedRandom());
PartyRules.EnsureDeck(save, Db);
save.gold = 10000;
Assert.AreEqual(100, PartyRules.RemoveCost(save));
Assert.IsTrue(PartyRules.TryRemoveCard(save, 0));
Assert.AreEqual(200, PartyRules.RemoveCost(save));
Assert.IsTrue(PartyRules.TryRemoveCard(save, 0));
Assert.AreEqual(PartyRules.DeckMin, save.deck.Count);
Assert.IsFalse(PartyRules.TryRemoveCard(save, 0));
}
[Test]
public void SoPegaCartaQueFoiOferecida()
{
var save = MetaRules.NewGame(Db, new ScriptedRandom());
PartyRules.EnsureDeck(save, Db);
PartyRules.RollCardReward(save, Db, new ScriptedRandom());
Assert.AreEqual(3, save.cardChoices.Count);
Assert.IsFalse(PartyRules.TakeCard(save, "nao-existe"));
Assert.IsTrue(PartyRules.TakeCard(save, save.cardChoices[0]));
Assert.AreEqual(11, save.deck.Count);
Assert.AreEqual(0, save.cardChoices.Count);
}
[Test]
public void ArvoreTemCustoCrescenteEPreRequisito()
{
var save = MetaRules.NewGame(Db, new ScriptedRandom());
save.souls = 10;
var fury = SkillTree.Knight[0];
var combo = SkillTree.Knight[1];
Assert.IsFalse(SkillTree.CanBuy(save, "knight", combo));
Assert.IsTrue(SkillTree.TryBuy(save, "knight", fury));
Assert.AreEqual(2, SkillTree.Cost(save, "knight", fury));
Assert.IsTrue(SkillTree.CanBuy(save, "knight", combo));
Assert.AreEqual(9, save.souls);
}
[Test]
public void ArvoreDaBruxaMexeNaMana()
{
var save = MetaRules.NewGame(Db, new ScriptedRandom());
save.souls = 10;
SkillTree.TryBuy(save, "witch", SkillTree.Witch[0]);
var loadout = HeroLoadout.Fresh(Db.Hero("witch"));
SkillTree.Apply(save, "witch", loadout);
Assert.AreEqual(Db.Hero("witch").maxMana + 1, loadout.MaxMana);
}
[Test]
public void MissaoZeraNoDiaSeguinteEPagaUmaVez()
{
var save = MetaRules.NewGame(Db, new ScriptedRandom());
var bosses = QuestRules.All[0];
QuestRules.Today(save, "2026-01-01");
for (int i = 0; i < 5; i++)
QuestRules.Record(save, "2026-01-01", new Battle.BattleEnd { Kind = Battle.DungeonOutcomeKind.Victory, BossKilled = true }, 0, 0);
int gold = save.gold;
Assert.AreEqual(600, QuestRules.Claim(save, "2026-01-01", bosses));
Assert.AreEqual(0, QuestRules.Claim(save, "2026-01-01", bosses));
Assert.AreEqual(gold + 600, save.gold);
QuestRules.Today(save, "2026-01-02");
Assert.AreEqual(0, QuestRules.Progress(save, bosses));
}
}
}
🧪 Teste rápido
Window → General → Test Runner → EditMode → Run All. Todos verdes: os do Guerreiro e os 17 novos.
✅ Checkpoint da Fase 7
PartyTestseWitchDeckTestspassam.- Os testes herdados continuam passando (eles usam o
WarriorDef()da Etapa 1B). - Commit:
test: dupla, deck, árvore e missões.
Problemas comuns
ArgumentException: Herói desconhecido: knight: os dados não foram recriados depois da Etapa 1B. Rode OKW → Criar ou atualizar dados iniciais.CavaleiroLentoAgeDepoisDoServofalha: o assetknightnão tem Speed6. Rode o seeder de novo.
Próxima fase: Fase 8 — Build, anúncios e próximos passos.
Parte 11 — Implementação Unity: Fase 8 (Build, anúncios e próximos passos)
Objetivo desta fase: o jogo rodando no celular Android, em retrato, com os anúncios da Parte 1 §13.
8.1 Anúncios
Siga a Fase 9 do Guerreiro (GuerreiroDaMasmorra.md, Parte 12) só na parte dos anúncios (ProviderAds): intersticial a cada 5 entradas (o GameSession já chama), vídeo premiado para dobrar o bônus e para o Baú do Dia. A nuvem (CloudSave) fica de fora: login e save na nuvem estão Em aberto (Parte 1 §13).
8.2 Build de Android
- File → Build Profiles → Android → Switch Platform.
- Player Settings:
- Company Name e Product Name (
Old Knight and the Witch); - Default Orientation
Portrait; - Other Settings → Scripting Backend
IL2CPP, Target ArchitecturesARM64; - Minimum API Level: o que o pacote de anúncios pedir.
- Company Name e Product Name (
- Graphics: o nível de qualidade Mobile (Etapa 0B). Se o celular de teste esquentar ou cair abaixo de 30 fps:
- baixe Shadow Resolution no
Mobile_RPAssetpara512; - deixe Per Object Limit das luzes adicionais em
2; - aumente o
torchEverydos biomas interiores.
- baixe Shadow Resolution no
- Build And Run com o celular ligado por USB, com a depuração USB ativada.
🧪 Teste rápido
No celular: o acampamento abre em retrato; a masmorra mostra o corredor iluminado sem travar; os gestos (tocar, arrastar para cima e para baixo, deslizar para os lados) funcionam no QTE; segurar uma carta a joga.
✅ Checkpoint da Fase 8
- APK instalado e jogável do acampamento à masmorra 3.
- Intersticial na 5ª entrada; vídeo premiado no fim da masmorra e no baú.
- Commit:
chore: build Android.
8.3 Próximos passos
- Protótipo web antes do Android (Parte 1 §1): o jogo pode ser testado primeiro no navegador. Há dois caminhos: a build WebGL deste projeto (o módulo instalado na Etapa 0A) ou um protótipo em HTML e JS no repositório de GDDs. O protótipo seria uma cópia do
prototipos/guerreiro-da-masmorra-app-3d/com a dupla e as cartas; a decisão de qual usar está Em aberto. - Arte: gerar no PixelLab a dupla, o elenco e os objetos de cena (Parte 1 §10) e preencher o
VisualCatalog, os biomas e os retratos doPartyHud. - Balanceamento: todos os números marcados como proposta na Parte 1 dependem do playtest. Isso vale principalmente para a velocidade do Cavaleiro, as 2 vagas da Bruxa, a mana e o custo das cartas.
- Itens Em aberto da Parte 1 §15: nomes e história, bônus de troca, cartas raras e evolução, gemas da Bruxa, áudio, nuvem.
Versão: rascunho inicial. A refinar com o protótipo.