📊 P18: Radar de Habilidades (Gráficos Customizados com Canvas API)

Bem-vindo à segunda missão da Fase 9: Mídia & Gráficos Customizados! 📊

Neste projeto de programação gráfica avançada e de baixo nível, você aprenderá a desenhar geometrias vetoriais complexas diretamente nos pixels do monitor através da poderosa Canvas API do Jetpack Compose, utilizando trigonometria matemática básica (seno e cosseno). O tema do app será a clássica ficha poligonal de RPG (Radar Chart / Spider Chart) que mapeia dinamicamente os atributos físicos e mágicos (Força, Agilidade, Furtividade, Magia, Defesa) de classes de personagens (Guerreiro, Ladino, Mago) de forma totalmente animada na tela.


✅ Pré-requisitos

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


🎯 Objetivo do Projeto

Desenhar gráficos customizados interativos contendo:

graph TD
    A[Usuário seleciona classe Guerreiro ou Mago] --> B[Dispara recomposição na MainActivity]
    B --> C[Canvas executa bloco de desenho sob demanda]
    C --> D[Desenhar eixos poligonais e teias de fundo com drawLine]
    D --> E[Calcular vértices matemáticos do Herói usando seno/cosseno e porcentagem]
    E --> F[Construir Path ligando os vértices calculados]
    F --> G[drawPath pinta o miolo com cor semitransparente Ciano]
    G --> H[drawPath desenha contorno Stroke ciano brilhante]

📖 Dicionário do Projeto


🛠️ Passo 1: Entendendo o Sistema de Coordenadas Canvas

O topo esquerdo do Canvas é sempre a coordenada (0, 0). O centro do Canvas é calculado como Offset(size.width / 2, size.height / 2). O raio máximo do nosso radar é a metade da largura menor multiplicada por uma porcentagem de margem de segurança.


🛠️ Passo 2: Calculando os Ângulos dos Vértices (Trigonometria)

Como o nosso gráfico é um pentágono (5 atributos), dividimos o círculo completo (360º ou 2π radianos) por 5 eixos.

A fórmula para encontrar a coordenada (X, Y) de cada vértice baseada na força do atributo do herói (statsArray[j]) é:

val angle = (2 * Math.PI * j / numAxes) - Math.PI / 2 // -90 graus para iniciar no topo
val statRadius = maxRadius * statsArray[j]
val x = center.x + (statRadius * cos(angle)).toFloat()
val y = center.y + (statRadius * sin(angle)).toFloat()

🧠 Passo 3: Codificando os Traçados no Canvas

No seu arquivo MainActivity.kt, inicialize o bloco Canvas e pinte o polígono preenchendo o fundo e o contorno:

Canvas(modifier = Modifier.fillMaxSize()) {
    val heroPath = Path()
    // Laço calcula os 5 pontos geométricos
    for (j in 0 until numAxes) {
        val angle = (2 * Math.PI * j / numAxes) - Math.PI / 2
        val statRadius = maxRadius * statsArray[j]
        val x = center.x + (statRadius * cos(angle)).toFloat()
        val y = center.y + (statRadius * sin(angle)).toFloat()
        if (j == 0) heroPath.moveTo(x, y) else heroPath.lineTo(x, y)
    }
    heroPath.close()

    // 1. Pinta o Miolo
    drawPath(path = heroPath, color = Color(0xFF00E5FF).copy(alpha = 0.3f))
    // 2. Desenha o Contorno
    drawPath(path = heroPath, color = Color(0xFF00E5FF), style = Stroke(width = 4f))
}

🏆 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/radar/MainActivity.kt

package br.com.curso.radar

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.Canvas
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.cos
import kotlin.math.sin

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

@Composable
fun RadarTheme(content: @Composable () -> Unit) {
    MaterialTheme(
        colorScheme = darkColorScheme(
            primary = Color(0xFF00E5FF), // Ciano
            background = Color(0xFF0D0F12),
            surface = Color(0xFF15191E)
        ),
        content = content
    )
}

// 1. DATA MODEL PARA HABILIDADES RPG
data class CharacterStats(
    val name: String,
    val strength: Float, // Valores de 0.0 a 1.0 (0% a 100%)
    val agility: Float,
    val stealth: Float,
    val magic: Float,
    val defense: Float
)

val heroStats = listOf(
    CharacterStats("Guerreiro", 0.9f, 0.4f, 0.2f, 0.1f, 0.8f),
    CharacterStats("Ladino", 0.3f, 0.9f, 0.9f, 0.3f, 0.4f),
    CharacterStats("Mago", 0.1f, 0.5f, 0.4f, 0.9f, 0.3f)
)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RadarScreen() {
    var selectedIndex by remember { mutableStateOf(0) }
    val currentHero = heroStats[selectedIndex]

    Scaffold(
        topBar = {
            SmallTopAppBar(
                title = { Text("RPG STATS RADAR CHART", fontWeight = FontWeight.Bold, letterSpacing = 2.sp) },
                colors = TopAppBarDefaults.smallTopAppBarColors(
                    containerColor = Color(0xFF15191E),
                    titleContentColor = Color(0xFF00E5FF)
                )
            )
        }
    ) { padding ->
        Column(
            modifier = Modifier
                .padding(padding)
                .fillMaxSize()
                .background(Color(0xFF0D0F12))
                .padding(24.dp),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            // Seletor de Classe
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                heroStats.forEachIndexed { index, hero ->
                    Button(
                        onClick = { selectedIndex = index },
                        colors = ButtonDefaults.buttonColors(
                            containerColor = if (selectedIndex == index) Color(0xFF00E5FF) else Color(0xFF15191E)
                        )
                    ) {
                        Text(
                            text = hero.name,
                            color = if (selectedIndex == index) Color.Black else Color.White,
                            fontWeight = FontWeight.Bold
                        )
                    }
                }
            }

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

            // 2. CANVAS DRAWING (RADAR / SPIDER CHART)
            Box(
                modifier = Modifier
                    .size(280.dp)
                    .background(Color(0xFF15191E), shape = RoundedCornerShape(16.dp))
                    .padding(16.dp),
                contentAlignment = Alignment.Center
            ) {
                Canvas(modifier = Modifier.fillMaxSize()) {
                    val center = Offset(size.width / 2, size.height / 2)
                    val maxRadius = size.width / 2 * 0.8f
                    val numAxes = 5 // Pentágono

                    // Desenhar Círculos/Teias Concêntricas de Fundo
                    val steps = 4
                    for (i in 1..steps) {
                        val radius = maxRadius * (i.toFloat() / steps)
                        val teiaPath = Path()
                        for (j in 0..numAxes) {
                            val angle = (2 * Math.PI * j / numAxes) - Math.PI / 2
                            val x = center.x + (radius * cos(angle)).toFloat()
                            val y = center.y + (radius * sin(angle)).toFloat()
                            if (j == 0) teiaPath.moveTo(x, y) else teiaPath.lineTo(x, y)
                        }
                        teiaPath.close()
                        drawPath(
                            path = teiaPath,
                            color = Color.Gray.copy(alpha = 0.3f),
                            style = Stroke(width = 2f)
                        )
                    }

                    // Desenhar Eixos dos Atributos
                    for (j in 0 until numAxes) {
                        val angle = (2 * Math.PI * j / numAxes) - Math.PI / 2
                        val x = center.x + (maxRadius * cos(angle)).toFloat()
                        val y = center.y + (maxRadius * sin(angle)).toFloat()
                        drawLine(
                            color = Color.Gray.copy(alpha = 0.4f),
                            start = center,
                            end = Offset(x, y),
                            strokeWidth = 2f
                        )
                    }

                    // Desenhar Polígono do Herói Ativo (Mapeando força dos atributos)
                    val statsArray = floatArrayOf(
                        currentHero.strength,
                        currentHero.agility,
                        currentHero.stealth,
                        currentHero.magic,
                        currentHero.defense
                    )

                    val heroPath = Path()
                    for (j in 0 until numAxes) {
                        val angle = (2 * Math.PI * j / numAxes) - Math.PI / 2
                        val statRadius = maxRadius * statsArray[j]
                        val x = center.x + (statRadius * cos(angle)).toFloat()
                        val y = center.y + (statRadius * sin(angle)).toFloat()
                        if (j == 0) heroPath.moveTo(x, y) else heroPath.lineTo(x, y)
                    }
                    heroPath.close()

                    // Pinta o miolo do polígono
                    drawPath(
                        path = heroPath,
                        color = Color(0xFF00E5FF).copy(alpha = 0.3f)
                    )
                    // Contorno do polígono
                    drawPath(
                        path = heroPath,
                        color = Color(0xFF00E5FF),
                        style = Stroke(width = 4f)
                    )
                }
            }

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

            // Legenda Didática
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(containerColor = Color(0xFF15191E)),
                shape = RoundedCornerShape(12.dp)
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text("ATRIBUTOS ATIVOS:", fontWeight = FontWeight.Bold, color = Color.White)
                    Spacer(modifier = Modifier.height(8.dp))
                    Text("Força: ${(currentHero.strength * 100).toInt()}% | Agilidade: ${(currentHero.agility * 100).toInt()}%", color = Color.Gray)
                    Text("Furtividade: ${(currentHero.stealth * 100).toInt()}% | Magia: ${(currentHero.magic * 100).toInt()}%", color = Color.Gray)
                }
            }
        }
    }
}