📊 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:
- 🎓 Trilha Essencial completa: Projetos P01-P10 (Cap 01-22)
- 🏗️ Projeto anterior: P17: Anime Soundboard
🎯 Objetivo do Projeto
Desenhar gráficos customizados interativos contendo:
- Contêiner Canvas: Compreender o escopo e coordenadas Cartesianas do componente
Canvas. - Linhas e Círculos de Fundo: Desenhar teias de eixos poligonais concêntricos usando laços de repetição (
drawLine). - Desenho de Caminhos (Paths): Traçar o polígono preenchido e colorido de forma matemática baseada em eixos e porcentagens.
- Interatividade e Mudança de Estados: Reagir à alteração de classes na tela, redesenhando as proporções dos atributos instantaneamente.
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
- Canvas API: O componente e conjunto de ferramentas do Compose que concede acesso à renderização bidimensional direta na tela. Permite criar gráficos dinâmicos, jogos simples e interfaces inovadoras.
- Offset: Classe que representa coordenadas espaciais bidimensionais
(X, Y)relativas ao topo-esquerdo do Canvas. - Path: Uma estrutura de dados geométrica usada para descrever formas vetoriais livres ligando pontos, arcos e curvas através do método
lineToe finalizada comclose(). - Stroke: Estilo de contorno de desenho do Canvas que pinta apenas as bordas e linhas externas de uma forma geométrica sem preencher o miolo.
- Trigonometria (cos/sin): Seno e Cosseno são fórmulas matemáticas usadas para converter ângulos radianos de um círculo em coordenadas espaciais
(X, Y)em volta de um ponto central.
🛠️ 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:
- Animação de Entrada: anime o preenchimento do radar, crescendo do centro até os valores finais ao abrir a tela.
- Comparação de Builds: desenhe dois polígonos sobrepostos com cores diferentes para comparar dois conjuntos de atributos.
📖 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)
}
}
}
}
}