⚔️ P21: Duelo Jedi (Multiplayer em Tempo Real com Firebase Realtime Database)

Bem-vindo ao projeto ápice da Fase 10: Robustez Técnica & Tempo Real! ⚔️

Nesta missão de engenharia avançada de redes e conexões móveis, você aprenderá a estabelecer comunicação bidirecional ultrarrápida de dados na nuvem para sincronizar o estado lógico de um jogo de combate entre dois smartphones físicos rodando o aplicativo simultaneamente. Utilizaremos o poder de sincronização reativa NoSQL do Firebase Realtime Database (ou canais abertos de Websockets) para disparar ataques e curas em um duelo de sabres de luz de Star Wars, alterando o HP do adversário no outro lado do mundo em frações de milissegundos.


✅ Pré-requisitos

Antes de começar, certifique-se de já ter estudado:


🎯 Objetivo do Projeto

Estruturar a comunicação reativa bidirecional em tempo real contendo:

graph TD
    A[Jogador 1 clica em Atacar ⚔️] --> B[Dispara gravação local na raiz /duels/sala_1/opponentHp]
    B --> C[Firebase Database recebe valor na Nuvem instantaneamente]
    C -->|Notificação de Rede| D[Listener do Jogador 2 escuta alteração na raiz /duels/sala_1]
    D --> E[Extrai dados da sala e atualiza DuelState do Compose local]
    E --> F[Interface do Jogador 2 renderiza HP caindo em frações de milissegundos]

📖 Dicionário do Projeto


🛠️ Passo 1: Configurando o Firebase no Projeto (Gradle)

  1. Abra o arquivo build.gradle (Module :app) e inclua a biblioteca oficial do Realtime Database:
    dependencies {
    // Firebase Realtime Database SDK
    implementation "com.google.firebase:firebase-database-ktx:20.3.0"
    ...
    }
    

    Clique em Sync Now.


🛠️ Passo 2: Codificando a Escuta e Escrita reativa na Nuvem

No seu arquivo MainActivity.kt, declare a referência da sala de batalha do Firebase e mapeie a gravação de ataques ao clicar no botão:

import com.google.firebase.database.FirebaseDatabase

// 1. Declarar a referência do banco
val databaseRef = FirebaseDatabase.getInstance().getReference("duels/sala_01")

// 2. Gravar alteração de HP na Nuvem ao atacar
databaseRef.child("opponentHp").setValue(newOpponentHp)
databaseRef.child("logMessage").setValue("Jogador 1 desferiu ataque de sabre de luz!")

// 3. Ouvir alterações em tempo real vinda da Nuvem
databaseRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val duelState = snapshot.getValue(DuelState::class.java)
        if (duelState != null) {
            // Atualiza o estado Compose local da UI
            state = duelState
        }
    }
    override fun onCancelled(error: DatabaseError) {}
})

🧠 Passo 3: Garantindo a Experiência Reativa Multiplayer

Para fins de teste sem configurações de servidores complexas, nosso gabarito contém uma função inteligente simulateOpponentAction que atua como o motor de Inteligência Artificial Sith em threads paralelas, simulando a resposta ativa de rede e permitindo que o aluno consolide a lógica antes de implantar chaves de produção físicas.


🏆 Desafios para você (Upgrade!)

Se você terminou de programar a funcionalidade base e tudo está funcionando, experimente estes upgrades:


📖 Gabarito Oficial de Código (Para Conferência)

Use os códigos Kotlin completos abaixo para validar sua implementação.

📄 Código de Lógica e Tela Completa (MainActivity.kt)

Disponível em: app/src/main/java/br/com/curso/duelojedi/MainActivity.kt

package br.com.curso.duelojedi

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            JediTheme {
                JediDuelScreen()
            }
        }
    }
}

@Composable
fun JediTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(
            primary = Color(0xFF00E676), // Sabre Verde
            background = Color(0xFF06090C),
            surface = Color(0xFF121820)
        ),
        content = content
    )
}

// 1. MODELO DE ESTADO DA ARENA MULTIPLAYER
data class DuelState(
    val playerHp: Int = 100,
    val opponentHp: Int = 100,
    val isMyTurn: Boolean = true,
    val logMessage: String = "Que a Força esteja com você. Duelo iniciado!"
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JediDuelScreen() {
    var state by remember { mutableStateOf(DuelState()) }
    val coroutineScope = rememberCoroutineScope()

    // 2. SIMULAÇÃO DE ESCUTA DE REDE BIDIRECIONAL (WEBSOCKET / FIREBASE REALTIME DB)
    fun simulateOpponentAction() {
        coroutineScope.launch {
            delay(2000) // Simula 2 segundos de raciocínio do oponente em outro celular
            val damage = (10..25).random()
            val newPlayerHp = if (state.playerHp - damage < 0) 0 else state.playerHp - damage
            state = state.copy(
                playerHp = newPlayerHp,
                isMyTurn = true,
                logMessage = "Oponente golpeou você! Dano de -$damage HP!"
            )
        }
    }

    Scaffold(
        topBar = {
            SmallTopAppBar(
                title = { Text("JEDI REALTIME DUEL v1.0", fontWeight = FontWeight.Black, letterSpacing = 2.sp) },
                colors = TopAppBarDefaults.smallTopAppBarColors(
                    containerColor = Color(0xFF121820),
                    titleContentColor = Color(0xFF00E676)
                )
            )
        }
    ) { padding ->
        Column(
            modifier = Modifier
                .padding(padding)
                .fillMaxSize()
                .background(Color(0xFF06090C))
                .padding(24.dp),
            verticalArrangement = Arrangement.SpaceBetween,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // Status do HP do Oponente
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text("OPONENTE (SITH)", color = Color.Red, fontWeight = FontWeight.Bold, fontSize = 12.sp)
                Spacer(modifier = Modifier.height(4.dp))
                LinearProgressIndicator(
                    progress = state.opponentHp / 100f,
                    color = Color.Red,
                    trackColor = Color.Red.copy(alpha = 0.2f),
                    modifier = Modifier.fillMaxWidth().height(16.dp).background(Color.Black, shape = RoundedCornerShape(8.dp))
                )
                Text("${state.opponentHp} / 100 HP", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
            }

            // Log de Ações em tempo real
            Card(
                modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF121820)),
                shape = RoundedCornerShape(12.dp)
            ) {
                Box(modifier = Modifier.padding(20.dp), contentAlignment = Alignment.Center) {
                    Text(
                        text = state.logMessage,
                        color = Color.White,
                        fontWeight = FontWeight.Medium,
                        fontSize = 16.sp
                    )
                }
            }

            // Status do HP do Jogador
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                Text("VOCÊ (JEDI)", color = Color(0xFF00E676), fontWeight = FontWeight.Bold, fontSize = 12.sp)
                Spacer(modifier = Modifier.height(4.dp))
                LinearProgressIndicator(
                    progress = state.playerHp / 100f,
                    color = Color(0xFF00E676),
                    trackColor = Color(0xFF00E676).copy(alpha = 0.2f),
                    modifier = Modifier.fillMaxWidth().height(16.dp).background(Color.Black, shape = RoundedCornerShape(8.dp))
                )
                Text("${state.playerHp} / 100 HP", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
            }

            Spacer(modifier = Modifier.height(32.dp))

            // Ações de Turno
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                Button(
                    onClick = {
                        if (state.isMyTurn && state.playerHp > 0 && state.opponentHp > 0) {
                            val damage = (15..30).random()
                            val newOpponentHp = if (state.opponentHp - damage < 0) 0 else state.opponentHp - damage
                            state = state.copy(
                                opponentHp = newOpponentHp,
                                isMyTurn = false,
                                logMessage = "Você atacou com o Sabre de Luz! Dano de -$damage no Sith!"
                            )
                            simulateOpponentAction()
                        }
                    },
                    enabled = state.isMyTurn && state.playerHp > 0 && state.opponentHp > 0,
                    colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF00E676))
                ) {
                    Text("ATACAR ⚔️", color = Color.Black, fontWeight = FontWeight.Bold)
                }

                Button(
                    onClick = {
                        if (state.isMyTurn && state.playerHp > 0 && state.opponentHp > 0) {
                            val cure = (10..20).random()
                            val newPlayerHp = if (state.playerHp + cure > 100) 100 else state.playerHp + cure
                            state = state.copy(
                                playerHp = newPlayerHp,
                                isMyTurn = false,
                                logMessage = "Você usou a Força para se curar! Recobrou +$cure HP!"
                            )
                            simulateOpponentAction()
                        }
                    },
                    enabled = state.isMyTurn && state.playerHp > 0 && state.opponentHp > 0,
                    colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF121820))
                ) {
                    Text("CURAR-SE ✨", color = Color.White)
                }
            }
        }
    }
}