📚 Pré-requisitos Teóricos: este projeto aplica conceitos ensinados em Especialização em Android Nativo. Recomendado revisar antes de começar.
Bem-vindo à nona etapa da Fase 7: Arquitetura Avançada & Qualidade de Código! ⚔️
Neste projeto prático, você aprenderá como construir uma Máquina de Estados Finita (Finite State Machine - FSM) utilizando o poder das sealed class do Kotlin combinadas com a arquitetura MVI (Model-View-Intent) e o padrão de fluxo unidirecional de dados (UDF - Unidirectional Data Flow).
Com a temática de uma arena de batalha medieval mística (Alliance Battle contra um Dragão Ancestral), você aprenderá como eliminar estados inválidos e inconsistentes do aplicativo em tempo de compilação, implementar uma engine de regras pura e escrever testes unitários automatizados com JUnit 5 (Jupiter) para validar 100% dos cálculos matemáticos de combate!
| 1. Estado Idle (Espera) | 2. Turno Ativo (Combate FSM) | 3. Estado Vitória (Terminal) |
|---|---|---|
| Arena aguardando o início do duelo. | Transições atômicas entre jogador e IA inimiga. | Estado terminal seguro com recompensa de EXP. |
Antes de iniciar este laboratório, certifique-se de ter compreendido:
Ao concluir este projeto autoguiado, você será capaz de:
sealed class: Representar todos os estados possíveis da aplicação de forma exaustiva e fortemente tipada.BattleEngine) do framework Android.stateDiagram-v2
[*] --> Idle: App Inicializado
Idle --> PlayerTurn: Clicar em "ENTRAR NA ARENA"
PlayerTurn --> EnemyTurn: Jogador Ataca ou Cura (EnemyHP > 0)
PlayerTurn --> Victory: Ataque zera o EnemyHP (EnemyHP <= 0)
EnemyTurn --> PlayerTurn: Contra-ataque do Dragão (PlayerHP > 0)
EnemyTurn --> Defeat: Golpe do Dragão zera PlayerHP (PlayerHP <= 0)
Victory --> PlayerTurn: Clicar em "JOGAR NOVAMENTE"
Defeat --> PlayerTurn: Clicar em "TENTAR NOVAMENTE"
[!IMPORTANT] O Princípio de “Tornar Estados Ilegais Irrepresentáveis”: Em arquiteturas legadas com múltiplos booleanos soltos (
var isLoading = false,var isGameOver = false,var isPlayerTurn = true), é comum o app entrar em deadlocks ou combinações impossíveis de flags.Nossa Estratégia de Blindagem Arquitetural:
- Enumeração Fechada com
sealed class: O compilador do Kotlin garante exaustividade nowhen (state)sem necessidade de blocoselsegenéricos perigosos.- Imutabilidade Atômica: Os objetos de estado são
data classcom propriedadesval, garantindo que uma transição de estado seja sempre um novo snapshot imutável.- Testabilidade Total via JUnit 5: Regras de combate são 100% puras (funções que recebem entradas e devolvem saídas determinísticas sem side effects).
- Higiene de Versionamento: Arquivo
.gitignoreconfigurado para isolar caches locais.
| Termo / Componente | O que é e para que serve? |
|---|---|
| FSM (Finite State Machine) | Modelo computacional que define um número finito de estados e regras determinísticas de transição entre eles. |
| Sealed Class | Hierarquia restrita de classes do Kotlin onde todas as subclasses diretas são conhecidas em tempo de compilação. |
| Unidirectional Data Flow (UDF) | Padrão onde o estado desce pela árvore de componentes e os eventos de interação sobem de forma unidirecional. |
| JUnit 5 (Jupiter) | Framework padrão da indústria para execução de testes unitários modernos em Java e Kotlin. |
| Pure Function | Função matemática cujo resultado depende exclusivamente de seus parâmetros, sem efeitos colaterais externos. |
File > New > New Project).Simulador de Batalha (ou android_p20_batalha_states)br.com.curso.batalhaKotlinAPI 24 ("Nougat"; Android 7.0) ou superiorGroovy DSL (build.gradle) ou Kotlin DSL (build.gradle.kts)build.gradle)Abra app > build.gradle:
android {
testOptions {
unitTests.all {
useJUnitPlatform()
}
}
}
dependencies {
implementation platform('androidx.compose:compose-bom:2024.02.00')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.ui:ui-graphics'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.material3:material3'
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
// JUnit 5 (Jupiter) para testes unitários
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
}
AndroidManifest.xml)Abra app > src > main > AndroidManifest.xml:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="Simulador de Batalha"
android:supportsRtl="true"
android:theme="@style/Theme.CartaoTreinador">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
BattleState)Abra app > src > main > java > br > com > curso > batalha > MainActivity.kt:
sealed class BattleState {
data object Idle : BattleState()
data class PlayerTurn(val playerHp: Int, val enemyHp: Int, val log: String, val turno: Int) : BattleState()
data class EnemyTurn(val playerHp: Int, val enemyHp: Int, val log: String, val turno: Int) : BattleState()
data class Victory(val totalTurns: Int, val expGained: Int) : BattleState()
data class Defeat(val killerMove: String) : BattleState()
}
BattleEngine)object BattleEngine {
fun calcularDano(ataque: Int, defesa: Int, multiplicador: Float = 1.0f): Int {
val danoBruto = ataque - (defesa / 2)
val danoMinimo = if (danoBruto < 1) 1 else danoBruto
return (danoMinimo * multiplicador).toInt()
}
fun aplicarCura(hpAtual: Int, cura: Int, maxHp: Int = 100): Int {
return (hpAtual + cura).coerceAtMost(maxHp)
}
fun avaliarResultadoCombate(playerHp: Int, enemyHp: Int, turnos: Int): BattleState? {
return when {
enemyHp <= 0 -> BattleState.Victory(totalTurns = turnos, expGained = 250)
playerHp <= 0 -> BattleState.Defeat(killerMove = "Sopro Incendiário do Dragão")
else -> null
}
}
}
BattleEngineTest.kt)Abra app > src > test > java > br > com > curso > batalha > BattleEngineTest.kt:
class BattleEngineTest {
@Test
fun `dano comum deve reduzir metade da defesa do inimigo`() {
val dano = BattleEngine.calcularDano(ataque = 20, defesa = 10)
assertEquals(15, dano)
}
@Test
fun `dano minimo deve ser sempre 1 mesmo se a defesa for extremamente alta`() {
val dano = BattleEngine.calcularDano(ataque = 5, defesa = 50)
assertEquals(1, dano)
}
@Test
fun `cura nao deve ultrapassar o limite maximo de HP de 100`() {
val hpFinal = BattleEngine.aplicarCura(hpAtual = 90, cura = 25, maxHp = 100)
assertEquals(100, hpFinal)
}
@Test
fun `calcular proximo estado deve retornar Vitoria quando o HP do inimigo zera`() {
val proximoEstado = BattleEngine.avaliarResultadoCombate(playerHp = 45, enemyHp = 0, turnos = 3)
assertTrue(proximoEstado is BattleState.Victory)
}
}
@Composable
fun BatalhaScreen() {
var state by remember { mutableStateOf<BattleState>(BattleState.Idle) }
when (val s = state) {
is BattleState.Idle -> { /* Renderiza Card de Entrada na Arena */ }
is BattleState.PlayerTurn, is BattleState.EnemyTurn -> { /* Renderiza Arena com Barras de HP e Botões */ }
is BattleState.Victory -> { /* Renderiza Tela de Vitória com EXP */ }
is BattleState.Defeat -> { /* Renderiza Tela de Derrota */ }
}
}
| Sintoma Observado | Causa Provável | Como Resolver |
|---|---|---|
Test framework not found ao rodar ./gradlew test |
O bloco unitTests.all { useJUnitPlatform() } não foi configurado no build.gradle. |
Adicione a instrução useJUnitPlatform() dentro de android.testOptions para habilitar o JUnit 5. |
| O HP do jogador ultrapassa 100 após curar | Falta do limitador de teto na função de cura. | Use .coerceAtMost(maxHp) na função aplicarCura(). |
| O turno do inimigo executa instantaneamente sem dar tempo de ler | A chamada de contra-ataque não foi encapsulada com delay() em uma corrotina. |
Utilize coroutineScope.launch { delay(1200); ... } para simular o tempo de resposta da IA. |
Erro de compilação when expression must be exhaustive |
Um novo estado foi adicionado na sealed class, mas não foi tratado no when. |
Trate o novo estado explicitamente para manter o código seguro e previsível. |
LazyColumn expansível.Disponível em: app/src/main/java/br/com/curso/batalha/MainActivity.kt.
No seu editor/IDE, abra a pasta deste projeto (File > Open Folder) ou navegue via terminal:
cd proj_aplicacoes_full_stack/projetos/android_p20_batalha_states
./gradlew test
./gradlew build
# ou abrir no Android Studio e clicar em Run (Shift+F10)
[!TIP] Dica para execução a partir da raiz do repositório: Se você abriu o repositório completo no VS Code ou Android Studio, abra o projeto diretamente pela pasta
proj_aplicacoes_full_stack/projetos/android_p20_batalha_states.