Franquia Rally — Guia Unity
Fase 0 — Setup do Projeto
Objetivo desta fase: ter o Unity instalado, projeto criado, e a estrutura de pastas pronta antes de escrever qualquer script.
0.1 Instalar o Unity
- Baixe o Unity Hub em unity.com/download.
- Dentro do Hub, na aba Installs, clique Install Editor e escolha a versão LTS mais recente (ex.: 2022 LTS ou 6 LTS — qualquer LTS serve, evite versões "Beta"/"Tech Stream" sendo iniciante).
- Na tela de módulos, marque:
- Build Support da sua plataforma alvo (Windows/Mac + WebGL, se pensa em publicar no navegador).
0.2 Criar o projeto
- No Hub, aba Projects → New Project.
- Escolha o template 2D (Core) (não "2D URP" por enquanto — menos complexidade pra quem tá começando; dá pra migrar depois se precisar).
- Nome do projeto:
RallySurvive. - Clique Create Project e espere abrir o Editor.
0.3 Estrutura de pastas (dentro de Assets/)
Assim que abrir, crie esta estrutura na janela Project (botão direito → Create → Folder):
Assets/
_Project/
Scripts/
Car/
Track/
UI/
Firebase/
Sprites/
Car/
Tracks/
UI/
Particles/
Scenes/
Prefabs/
Materials/
Tilemaps/
SceneTemplates/
Dica: o prefixo
_Project(com underline) faz sua pasta ficar sempre no topo da lista, separada das pastas de packages/plugins.
0.4 Criar a primeira cena
As 3 pistas do lançamento (Deserto, Floresta, Gelo — ver Parte 2 §"Pistas") nascem do mesmo pipeline. Construímos a
RaceDesertprimeiro, com todo o sistema comum (carro, partículas, chevrons, pista, timer/UI), e a partir dela geramosRaceForesteRaceIcecomo pistas-template (Parte 10 §3.12) — só trocando o que é específico de cada uma (geometria da pista, poças, grip, partículas, chuva). Esta fase e as próximas 4 (Fase 1 a 4) descrevem aRaceDesertem detalhe completo; a Parte 10 §3.8 em diante mostra a diferença pras outras duas.
- Em
Assets/_Project/Scenes/, botão direito → Create → Scene. NomeieRaceDesert. - Dê duplo clique pra abrir.
- Salve o projeto (
Ctrl+S/Cmd+S).
0.5 Pacotes necessários (Window → Package Manager)
Instale agora, mesmo que só use mais adiante (evita interromper o fluxo depois):
- Input System (com.unity.inputsystem) — vamos usar pra ler a posição do mouse de forma moderna.
- TextMeshPro (geralmente já vem, ou é oferecido num popup na primeira vez que você usa texto — aceite o import de "TMP Essentials").
- 2D Sprite Shape (com.unity.2d.spriteshape) — pra desenhar o traçado da pista como uma textura contínua ao longo de uma spline (ver Fase 3).
- 2D Tilemap Editor (com.unity.2d.tilemap) — pra pintar o terreno de fundo (fora da pista) e apoiar a decoração.
Ativar o novo Input System
Edit → Project Settings → Player → Other Settings → Active Input Handling → mude para Input System Package (New) ou Both (mais seguro para iniciante, evita quebrar packages antigos). O Unity vai pedir pra reiniciar o Editor — aceite.
✅ Checkpoint da Fase 0
- Projeto abre sem erros.
- Estrutura de pastas criada.
- Cena
RaceDesertexiste e está salva. - Package Manager mostra Input System, TextMeshPro, Sprite Shape e Tilemap instalados.
Próxima fase: 01_CARRO_MOVIMENTO.md — vamos fazer o carro se mover em direção ao mouse.
Fase 1 — Movimento do Carro
Objetivo: o carro se move em direção ao cursor, a velocidade cresce (de forma exponencial) quanto mais longe o cursor estiver, e o quanto ele responde a isso depende do grip do terreno atual (seção 1.5) — em grip baixo o carro carrega velocidade/direção antigas por mais tempo antes de obedecer ao cursor.
1.1 Criar o GameObject do carro
- Na Hierarchy, botão direito → 2D Object → Sprite → Square (placeholder — depois trocamos pelo sprite real do carro).
- Renomeie para
Car. - Adicione componentes (botão Add Component no Inspector):
- Rigidbody2D → mude
Body TypeparaDynamic,Gravity Scalepara0(é topdown, sem gravidade), e marqueInterpolatecomoInterpolate(deixa o movimento mais suave visualmente). - Box Collider 2D (ou Circle, se o sprite for redondo).
- Rigidbody2D → mude
1.2 Script: CarController.cs
Crie em Assets/_Project/Scripts/Car/CarController.cs:
using UnityEngine;
using UnityEngine.InputSystem; // New Input System (Fase 0): é daqui que vem o Mouse.current
// POR QUE: é o coração do jogo — o único lugar que transforma "onde está o mouse" em
// "como o carro se move". Chevrons (Fase 2), partículas (§1.5), barra de velocidade (Fase 4)
// e sensor de terreno (Fase 3) todos LEEM ou ESCREVEM valores aqui, mas nenhum deles
// move o carro — isso fica concentrado nesta classe.
// ESTRATÉGIA: é um MonoBehaviour (script que vive preso a um GameObject da cena e recebe
// chamadas automáticas do Unity como Awake/Update). O carro é movido pela FÍSICA
// (Rigidbody2D), não teleportando o transform, pra colisões e interpolação funcionarem.
// A leitura do mouse fica no Update (1x por frame desenhado) e a física no FixedUpdate
// (passo fixo de física). O resultado é exposto em propriedades públicas somente-leitura
// (TargetSpeedNormalized, ActualSpeedNormalized, IsAccelerating, IsBraking) pra outros
// scripts consumirem sem poder bagunçar o estado.
//
// [RequireComponent] faz o Unity adicionar um Rigidbody2D automaticamente quando você
// arrasta este script num GameObject, e impede removê-lo — garante que GetComponent<Rigidbody2D>()
// no Awake nunca volte null.
[RequireComponent(typeof(Rigidbody2D))]
public class CarController : MonoBehaviour
{
// [Header("...")] só desenha um título no Inspector pra agrupar os campos abaixo.
// [SerializeField] faz um campo PRIVADO aparecer (e ser salvo) no Inspector — assim você
// ajusta valores sem recompilar, e outros scripts continuam sem poder mexer neles.
[Header("Speed")]
[SerializeField] private float maxSpeed = 8f; // velocidade máxima, em unidades do mundo por segundo
[SerializeField] private float minDistanceForMovement = 0.3f; // abaixo dessa distância cursor→carro, o carro para ("zona morta")
[SerializeField] private float maxDistanceForTopSpeed = 6f; // acima dessa distância, já está na velocidade máxima
[SerializeField] private AnimationCurve speedCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
// ^ curva suave "exponencial", editável num editor gráfico no Inspector (ver seção 1.4).
// Entrada: distância normalizada (0 a 1). Saída: fração da velocidade máxima (0 a 1).
[Header("Grip / Derrapagem")]
[SerializeField] private float steeringResponsiveness = 20f;
// ^ com grip 1.0, um valor alto aqui deixa a resposta quase idêntica ao "snap" direto de antes.
// Em grip baixo (poça, Gelo), a resposta efetiva cai proporcionalmente — ver FixedUpdate.
[Header("Rotation")]
[SerializeField] private float rotationSpeed = 10f; // quão rápido o sprite gira pra apontar pra direção do movimento
private Rigidbody2D rb; // referência cacheada ao componente de física (preenchida no Awake)
private Camera mainCamera; // câmera usada pra converter posição de tela → mundo
private Vector2 mouseWorldPos; // última posição do mouse em coordenadas do MUNDO (lida no Update, usada no FixedUpdate)
// Grip atual (0 a 1) do terreno embaixo do carro — setado externamente pelo
// CarTerrainSensor (Parte 10 §3.10), nunca calculado aqui dentro. Padrão 1 = grip
// total, então nada muda até a Fase 3 ligar o sensor.
// { get; set; } = propriedade pública: outros scripts leem e escrevem como se fosse campo.
public float CurrentGrip { get; set; } = 1f;
// true enquanto o carro estiver sobre uma poça (Floresta) — só troca o preset de
// partícula (seção 1.5), não afeta o grip em si (isso já está em CurrentGrip).
public bool InPuddle { get; set; }
// { get; private set; } = qualquer script pode LER, mas só esta classe pode ESCREVER.
// Velocidade PEDIDA pelo cursor (0 a 1) — alimenta os chevrons (intenção do jogador, Fase 2).
public float TargetSpeedNormalized { get; private set; }
// Velocidade REAL do carro (0 a 1), já filtrada pelo grip — alimenta a barra de
// velocidade da UI (Parte 11 §4.4) e as partículas (seção 1.5).
public float ActualSpeedNormalized { get; private set; }
public bool IsAccelerating { get; private set; } // true se o cursor está pedindo MAIS velocidade que no passo anterior
public bool IsBraking { get; private set; } // true se o cursor está pedindo MENOS velocidade que no passo anterior
// Velocidade pedida no passo de física anterior — comparando com a atual descobrimos
// se o jogador está acelerando ou freando.
private float previousTargetSpeedNormalized;
// Awake: o Unity chama UMA vez, assim que o objeto é criado/carregado na cena, antes de
// qualquer Start/Update. É o lugar certo pra pegar referências a componentes.
// Por que cachear: GetComponent e Camera.main fazem uma busca; fazer isso todo frame
// desperdiça CPU. Buscamos uma vez e guardamos no campo.
void Awake()
{
rb = GetComponent<Rigidbody2D>();
mainCamera = Camera.main; // a câmera com a tag "MainCamera" na cena
}
// Update: o Unity chama uma vez por FRAME desenhado (a taxa varia: 30, 60, 144 fps...).
// Input deve ser lido aqui, porque é onde o Unity atualiza o estado do mouse/teclado.
// Aqui só lemos e guardamos a posição do mouse — quem usa o valor é o FixedUpdate.
void Update()
{
// O mouse vem em PIXELS de tela; o carro vive em unidades de MUNDO.
// ScreenToWorldPoint converte uma coisa na outra usando a câmera.
Vector2 mouseScreenPos = Mouse.current.position.ReadValue();
mouseWorldPos = mainCamera.ScreenToWorldPoint(mouseScreenPos);
}
// FixedUpdate: o Unity chama num intervalo FIXO (padrão 0,02s = 50x por segundo),
// independente do fps. Toda mexida em Rigidbody (velocidade, rotação, forças) vai aqui,
// pra física se comportar igual em PC rápido e lento.
// O que faz: calcula a velocidade que o cursor está pedindo, aproxima a velocidade real
// dela respeitando o grip, gira o carro e publica o estado pros outros scripts.
void FixedUpdate()
{
Vector2 toMouse = mouseWorldPos - (Vector2)transform.position; // vetor carro → cursor
float distance = toMouse.magnitude;
// 1. Normaliza a distância pra faixa 0..1 (0 = na zona morta, 1 = na distância de velocidade máxima)
float t = Mathf.InverseLerp(minDistanceForMovement, maxDistanceForTopSpeed, distance);
t = Mathf.Clamp01(t);
// 2. Aplica a curva (exponencial suave) pra obter a velocidade ALVO (0..1).
// É isso que dá a sensação "no limite do controle": pouca resposta perto, explosiva longe.
float speedT = speedCurve.Evaluate(t);
// 3. Monta a velocidade que o jogador está PEDINDO (direção + velocidade alvo)
Vector2 desiredVelocity = Vector2.zero;
if (distance > minDistanceForMovement)
{
Vector2 direction = toMouse.normalized; // mesmo sentido, comprimento 1
desiredVelocity = direction * (speedT * maxSpeed);
// Gira o carro suavemente pra "olhar" na direção do movimento.
// Atan2 dá o ângulo do vetor em radianos → convertemos pra graus.
// O "- 90f" é porque o sprite aponta pra CIMA (eixo Y), e o ângulo 0 do Atan2 é pra DIREITA.
// LerpAngle anda uma fração do caminho a cada passo (suaviza) e lida com a virada 359°→0°.
// A rotação segue o cursor direto — só a velocidade é afetada pelo grip (passo 4).
float targetAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
float angle = Mathf.LerpAngle(rb.rotation, targetAngle, rotationSpeed * Time.fixedDeltaTime);
rb.MoveRotation(angle); // gira pela física (respeita a interpolação do Rigidbody2D)
}
// 4. Aproxima a velocidade REAL da velocidade pedida, numa taxa proporcional ao
// grip do terreno atual — isso é a derrapagem. Grip 1.0 (Deserto) responde quase
// instantaneamente; grip baixo (poça, Gelo) "carrega" velocidade antiga por mais
// tempo antes de alcançar o que o cursor está pedindo.
// Vector2.Lerp(a, b, x) = anda a fração x do caminho de a até b (x=0 fica, x=1 chega).
float effectiveResponsiveness = steeringResponsiveness * CurrentGrip;
float lerpT = Mathf.Clamp01(effectiveResponsiveness * Time.fixedDeltaTime);
rb.linearVelocity = Vector2.Lerp(rb.linearVelocity, desiredVelocity, lerpT);
// 5. Atualiza o estado público que outros scripts leem.
// O 0.001f é uma margem pra ignorar variações minúsculas (ruído), senão os chevrons piscariam.
TargetSpeedNormalized = speedT;
ActualSpeedNormalized = Mathf.Clamp01(rb.linearVelocity.magnitude / maxSpeed);
IsAccelerating = speedT > previousTargetSpeedNormalized + 0.001f;
IsBraking = speedT < previousTargetSpeedNormalized - 0.001f;
previousTargetSpeedNormalized = speedT;
}
}
⚠️ Nota de versão:
rb.linearVelocityé o nome usado no Unity 6 / 2023.x+. Se você estiver numa versão mais antiga (2022 LTS), troque porrb.velocity(mesma coisa, nome antigo).
1.3 Configurar no Inspector
- Arraste o script pro GameObject
Car. - Ajuste valores iniciais sugeridos:
Max Speed: 8Min Distance For Movement: 0.3Max Distance For Top Speed: 6Steering Responsiveness: 20 (com grip 1.0 já fica quase idêntico ao comportamento antigo; a diferença por pista entra na Fase 3, não aqui)Rotation Speed: 10
1.4 Configurar a curva exponencial suave
No Inspector, clique no campo Speed Curve (abre um editor gráfico de curva):
- Clique com botão direito no ponto do canto inferior esquerdo (0,0) → Left Tangent / Right Tangent → Linear, depois ajuste para algo que comece bem raso e suba rápido no final — ou mais simples:
- Clique com botão direito em qualquer ponto vazio da curva → Ease In já dá uma curva que começa devagar e acelera, que é a sensação que queremos (resposta mais "explosiva" perto do máximo).
Se preferir 100% via código sem mexer no Inspector, troque a linha da curva por:
// Substitui a linha "float speedT = speedCurve.Evaluate(t);" dentro do FixedUpdate.
// t * t (curva quadrática) cresce devagar perto de 0 e rápido perto de 1 — o mesmo efeito
// "exponencial suave" da AnimationCurve, só que fixo no código (não dá pra ajustar no Inspector).
float speedT = t * t;
1.5 Partículas de movimento
Objetivo: dar feedback visual de que o carro está em movimento e reagindo ao terreno — poeira em terreno seco (Deserto e Floresta fora de poça), respingo d'água ao cruzar uma poça (só na Floresta), e spray de gelo/neve no Gelo. A fonte de dado já existe:
CarController.ActualSpeedNormalizedeCarController.InPuddle(seção 1.2).
Definido: cada cena usa um preset de partícula fixo (trailParticles), exceto a Floresta, que alterna em tempo real entre o preset seco e o preset de respingo (splashParticles) conforme o carro entra/sai de uma poça (Parte 10 §3.9). Deserto e Gelo não precisam de troca em tempo real — o terreno é uniforme na pista inteira.
1. Criar o Particle System:
- Selecione o GameObject
Carna Hierarchy → Add Component → Particle System. Renomeie o componente/filho gerado paraCarTrailParticles. - Configure o básico pra um rastro atrás do carro:
Shape = ConeouCirclepequeno na posição da traseira,Simulation Space = World(pra partículas ficarem "no chão" enquanto o carro segue em frente), emissão baixa (Rate over Time~10-20),Start Lifetimecurto (~0.5-1s). - Ajuste a cor/textura pra poeira (tons de terra/areia) — isso vira o preset padrão do Deserto e da Floresta fora de poça.
- Só na cena
RaceForest(criada na Parte 10 §3.12): duplique esse Particle System (Ctrl+D), renomeie paraCarSplashParticles, troque a cor/textura pra respingo d'água (tons de azul/branco, partículas maiores e mais rápidas verticalmente). - Só na cena
RaceIce: troque a cor/textura doCarTrailParticlespra spray de gelo/neve (branco/ciano) — não precisa de um segundo Particle System, já que o Gelo não tem poças.
2. Script: CarParticles.cs
Crie em Assets/_Project/Scripts/Car/CarParticles.cs:
using UnityEngine;
// POR QUE: dar feedback visual de movimento/terreno (poeira, respingo, neve) sem colocar
// código de efeito visual dentro do CarController — o controlador cuida de física, este
// script cuida só de "pintar" o rastro.
// ESTRATÉGIA: MonoBehaviour que, todo frame, LÊ o estado público do CarController
// (ActualSpeedNormalized e InPuddle) e liga/desliga a emissão dos Particle Systems.
// Não cria nem destrói partículas: os Particle Systems já existem na cena (montados à
// mão no passo 1) e o script só abre/fecha a "torneira" (emission). Um único script serve
// pras 3 pistas: se o campo splashParticles estiver vazio, ele entende que a cena não tem poça.
public class CarParticles : MonoBehaviour
{
[SerializeField] private CarController car; // de onde lemos velocidade real e "está na poça?"
[SerializeField] private ParticleSystem trailParticles; // poeira (Deserto/Floresta) ou gelo/neve (Gelo)
[SerializeField] private ParticleSystem splashParticles; // respingo de poça — deixe vazio no Deserto e no Gelo
[SerializeField] private float minSpeedToEmit = 0.1f; // abaixo disso, carro "parado", sem partícula
// Update (1x por frame, ver CarController): efeito visual não é física, então não
// precisa do FixedUpdate. Decide qual preset deve estar emitindo agora.
void Update()
{
// Usa a velocidade REAL (pós-grip), não a pedida: se o carro derrapa parado no gelo,
// não faz sentido levantar poeira.
bool moving = car.ActualSpeedNormalized > minSpeedToEmit;
// "!= null" num campo de componente = "foi arrastado algo no Inspector?"
if (splashParticles != null)
{
// Cena com poça (Floresta): alterna entre os dois presets
SetEmitting(trailParticles, moving && !car.InPuddle);
SetEmitting(splashParticles, moving && car.InPuddle);
}
else
{
// Deserto/Gelo: um único preset fixo
SetEmitting(trailParticles, moving);
}
}
// Liga/desliga a emissão de um Particle System.
// Por que existe: evita repetir as mesmas 2 linhas 3 vezes no Update.
// Detalhe de Unity: ps.emission devolve um "módulo" (struct) que é só uma PONTE pro
// Particle System — alterar emission.enabled nele altera o sistema de verdade, mesmo
// sendo uma cópia local. Desligar a emissão não apaga as partículas já no ar: elas
// terminam a vida naturalmente (rastro some suave, sem "piscar").
// O "if" só escreve quando o valor muda, pra não mexer no sistema à toa todo frame.
private void SetEmitting(ParticleSystem ps, bool emit)
{
var emission = ps.emission;
if (emission.enabled != emit) emission.enabled = emit;
}
}
- Arraste o script no GameObject
Car. - No Inspector, ligue
Cara ele mesmo,Trail ParticlesaoCarTrailParticles, eSplash ParticlesaoCarSplashParticlessó na cenaRaceForest(deixe vazio nas outras duas).
Dica opcional: pra dar mais leitura de velocidade, escale
emission.rateOverTimeMultiplierproporcionalmente acar.ActualSpeedNormalizedem vez de só ligar/desligar — fica pra polimento, não é necessário pro MVP.
✅ Checkpoint da Fase 1
- Dar Play, mover o mouse longe do carro → carro acelera na direção dele.
- Aproximar o mouse do carro → carro desacelera e para.
- Movimento parece suave, sem tremer (nessa fase
CurrentGripainda está sempre em1, valor padrão — a diferença de derrapagem só aparece a partir da Fase 3, quando oCarTerrainSensorpassa a alterá-lo). - Partículas de poeira aparecem atrás do carro enquanto ele se move, e somem quando ele para.
Testando rápido
Adicione temporariamente um Debug.Log($"Target: {TargetSpeedNormalized}, Actual: {ActualSpeedNormalized}, Grip: {CurrentGrip}, Accel: {IsAccelerating}, Brake: {IsBraking}"); dentro do FixedUpdate pra confirmar que os valores mudam como esperado antes de seguir pra fase dos chevrons. Com CurrentGrip = 1 (padrão nesta fase), Target e Actual devem ficar bem próximos o tempo todo.
Próxima fase: 02_CHEVRONS.md — desenhar as setas verdes/vermelhas entre carro e cursor.
Fase 2 — Sistema de Chevrons
Objetivo: desenhar uma fileira de setas entre o carro e o cursor, verdes se acelerando, vermelhas se freando.
Nota: os chevrons leem
IsAccelerating/IsBraking, calculados a partir da velocidade pedida pelo cursor (TargetSpeedNormalized, Fase 1 §1.2) — eles mostram a intenção do jogador, não a velocidade real do carro (essa é a barra da UI, Fase 4 §4.4). Isso não muda entre pistas: os chevrons ficam iguais no Deserto, na Floresta e no Gelo, só a resposta física do carro é que muda (Fase 3, §3.8-3.10).
2.1 Criar o sprite/prefab do chevron
- Por enquanto, crie um sprite simples:
2D Object → Sprite → Triangle(serve de placeholder de seta — depois troca pelo spriteSprites/Rally2D/ui/chevron.png, que é branco justamente pra ser tingido de verde/vermelho pela cor doSpriteRenderer). - Renomeie para
ChevronArrow. - Arraste esse GameObject da Hierarchy pra pasta
Assets/_Project/Prefabs/— isso cria um Prefab. - Delete o objeto da Hierarchy (já está salvo como prefab, não precisa dele na cena).
2.2 Script: ChevronGuide.cs
Crie em Assets/_Project/Scripts/Car/ChevronGuide.cs:
using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections.Generic; // List<T>
// POR QUE: mostrar ao jogador o que o cursor está "pedindo" ao carro: setas verdes =
// acelerando, vermelhas = freando, mais setas = mais intensidade.
// O nome ChevronGuide é uma convenção compartilhada entre os 4 jogos da franquia (não renomear).
// ESTRATÉGIA: MonoBehaviour num GameObject próprio (não no carro), que só LÊ o estado do
// CarController (IsBraking) e a posição do mouse; ele nunca mexe no carro. Usa um "object pool":
// cria todas as setas uma única vez no Awake e, a cada frame, só liga/desliga e reposiciona
// as que precisa. Isso evita Instantiate/Destroy todo frame, que são caros e geram lixo
// de memória (o que causa travadinhas quando o coletor de lixo do C# roda).
public class ChevronGuide : MonoBehaviour
{
[SerializeField] private CarController car; // de onde lemos posição e IsBraking
[SerializeField] private GameObject chevronPrefab; // o prefab ChevronArrow (seção 2.1), "molde" de cada seta
[SerializeField] private int maxChevrons = 5; // tamanho do pool = máximo de setas na tela
[SerializeField] private float spacing = 0.8f; // distância entre cada seta, em unidades do mundo
[SerializeField] private Color accelerateColor = Color.green;
[SerializeField] private Color brakeColor = Color.red;
private Camera mainCamera;
private List<GameObject> pool = new List<GameObject>(); // as setas já criadas, reaproveitadas todo frame
// Awake (ver CarController): cacheia a câmera e monta o pool de setas uma única vez.
void Awake()
{
mainCamera = Camera.main;
// Object pool: cria todas as setas de antemão e depois só liga/desliga.
// Instantiate(prefab, transform) cria uma cópia do prefab como FILHA deste objeto
// (fica organizada na Hierarchy). SetActive(false) esconde e desliga a seta.
for (int i = 0; i < maxChevrons; i++)
{
GameObject chevron = Instantiate(chevronPrefab, transform);
chevron.SetActive(false);
pool.Add(chevron);
}
}
// Update (1x por frame, ver CarController): é visual, então roda no ritmo do frame.
// Recalcula quantas setas mostrar, onde, em que ângulo e com qual cor.
// Lê o mouse de novo (em vez de pegar do CarController) porque o CarController não
// expõe essa posição; a leitura é barata.
void Update()
{
Vector2 mouseScreenPos = Mouse.current.position.ReadValue();
Vector2 mouseWorldPos = mainCamera.ScreenToWorldPoint(mouseScreenPos);
Vector2 carPos = car.transform.position;
Vector2 direction = (mouseWorldPos - carPos);
float distance = direction.magnitude;
direction.Normalize(); // agora só "aponta", com comprimento 1
// Quantas setas mostrar: quantas "cabem" entre o carro e o cursor, limitado ao pool.
// Mais longe = mais setas = mais intensidade.
int chevronsToShow = Mathf.Clamp(
Mathf.FloorToInt(distance / spacing),
0,
maxChevrons
);
Color color = car.IsBraking ? brakeColor : accelerateColor;
for (int i = 0; i < pool.Count; i++)
{
if (i < chevronsToShow)
{
pool[i].SetActive(true);
// A seta i fica a (i+1) * spacing do carro, na linha carro → cursor.
Vector2 pos = carPos + direction * (spacing * (i + 1));
pool[i].transform.position = pos;
// Mesmo cálculo de ângulo do CarController (o sprite aponta pra cima, daí o -90).
// Quaternion.Euler(0, 0, angle) = rotação só no eixo Z, o único que importa em 2D.
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
pool[i].transform.rotation = Quaternion.Euler(0, 0, angle);
// Pinta a seta. (Otimização opcional pra depois: guardar os SpriteRenderers
// numa lista no Awake em vez de chamar GetComponent todo frame.)
var sr = pool[i].GetComponent<SpriteRenderer>();
if (sr != null) sr.color = color;
}
else
{
pool[i].SetActive(false);
}
}
}
}
2.3 Configurar na cena
- Crie um GameObject vazio na Hierarchy:
Create Empty→ renomeieChevronGuide. - Arraste o script
ChevronGuide.csnele. - No Inspector, arraste:
Car→ o GameObjectCarda cena.Chevron Prefab→ o prefabChevronArrowcriado no passo 2.1.
- Ajuste
Max Chevrons(5) eSpacing(0.8) conforme o tamanho da sua cena/sprites.
✅ Checkpoint da Fase 2
- Dar Play: mover o mouse para longe do carro → aparecem chevrons verdes entre carro e cursor.
- Mover o mouse rapidamente pra perto do carro (fazendo ele desacelerar) → chevrons ficam vermelhos.
- Quantidade de chevrons aumenta com a distância.
Problemas comuns
- Chevrons não aparecem: confirme que o prefab tem um
SpriteRenderervisível e que aSorting Layer/Order in Layerestá acima do chão da pista. - Chevrons ficam grudados no lugar errado: confira se
car.transform.positionestá pegando a posição certa (Z=0 no 2D).
Próxima fase: 03_PISTA_E_LIMITES.md — desenhar a pista com Sprite Shape e detectar quando o carro sai dela.
Fase 3 — Pista (Sprite Shape + Terreno) e Detecção de Saída da Pista
Objetivo: desenhar a superfície da pista como uma textura contínua (Sprite Shape), usar sprites/Tilemap só pro terreno de fundo e decoração, e detectar quando o carro sai do traçado válido, disparando o reinício da corrida.
Definido: a pista em si (o asfalto/terra por onde o carro anda) é renderizada como uma textura aplicada a uma malha gerada por spline (2D Sprite Shape), não como peças de tile encaixadas. Isso evita emendas visíveis em curvas orgânicas e casa com o formato de dados de pista do Core (waypoints + largura — ver Parte 1), já que a spline do Sprite Shape é, na prática, a mesma lista de waypoints. Tilemap e sprites soltos ficam reservados para terreno de fundo (grama, deserto, neve fora da pista) e decoração (pedras, árvores, placas) — nunca para desenhar o traçado.
3.1 Criar a pista com Sprite Shape
- Hierarchy → botão direito → 2D Object → Sprite Shape → Spline (use o preset Filled — é o que preenche o interior com uma textura, diferente do "Open" que é só uma linha/estrada sem preenchimento fechado).
- Renomeie o GameObject para
Track_Desert. - No componente Sprite Shape Controller, crie (ou peça pro seu artista criar) um Sprite Shape Profile: um asset que define qual sprite/textura preenche o interior (
Angle Range → Sprites, campo de fill) e quais sprites usar nas bordas/laterais (acostamento, guia da pista). - Edite os pontos de controle da spline na Scene view (clique com a ferramenta de edição de spline ativa) seguindo o traçado desejado da pista — esses pontos podem vir diretamente dos waypoints já definidos pra pista (Parte 1 — GDD Core).
- Ajuste a espessura da pista arrastando as alças de largura em cada ponto de controle (equivalente ao campo "largura de pista" dos dados de waypoint).
- No Sprite Shape Profile, configure o Fill Texture com a textura de asfalto/terra (vetorial flat, ver Parte 14 §7.5) — o Unity repete/estica essa textura automaticamente ao longo de toda a spline, sem você desenhar tile por tile.
Enquanto não tiver a textura final, use uma textura sólida simples (ex: cinza-chapado) só pra testar a forma e a lógica.
3.2 Terreno de fundo e decoração (Tilemap + sprites soltos)
O terreno que fica fora da pista (fora da área coberta pelo Sprite Shape) é pintado numa Tilemap separada, e a decoração é feita com sprites individuais posicionados à mão — nunca dentro da malha da pista.
- Hierarchy → botão direito → 2D Object → Tilemap → Rectangular. Renomeie para
Tilemap_Terrain. - Abra Window → 2D → Tile Palette, crie a paleta
Palette_DesertemAssets/_Project/Tilemaps/, e arraste os sprites de terreno (dunas, grama, gelo) pra dentro. - Pinte o terreno de fundo ao redor do traçado — não precisa ser preciso nas bordas, já que quem faz a fronteira visual real é a pista (Sprite Shape) por cima.
- Ajuste a Sorting Layer/Order in Layer:
Tilemap_Terrainno fundo,Track_Desert(Sprite Shape) acima do terreno, decoração e chevrons acima da pista, carro no topo. - Para decoração (pedras, cactos, árvores, bancos de neve), arraste sprites/prefabs individuais na cena — sem Tilemap, pra poder rotacionar/escalar cada um livremente e permitir sobreposição parcial na borda da pista (ex.: um arbusto que "invade" visualmente o acostamento).
3.3 Estratégia de detecção: zona válida vs. fora da pista
A forma mais simples pra iniciante (e é a mesma abordagem que vamos usar no RaceLegenda depois): criar um collider de trigger que cobre toda a área fora da pista (ou, alternativamente, um collider que cobre a pista e detectar quando o carro sai dele).
Abordagem recomendada: "zona válida" com Polygon Collider 2D.
- Crie um GameObject vazio:
Create Empty→ renomeieTrackValidZone. - Adicione componente Polygon Collider 2D.
- Marque a opção Is Trigger ✅.
- Edite os pontos do polígono (no Inspector, botão Edit Collider, ou arrastando os pontos verdes na Scene view) para desenhar o contorno exato da pista, seguindo o visual do Sprite Shape.
Dica: o Sprite Shape Controller tem a opção Collider Type → Trigger, que gera automaticamente um
Polygon Collider 2Dseguindo o contorno exato da spline — ativando isso, você pode usar esse collider gerado diretamente comoTrackValidZoneem vez de redesenhar o polígono manualmente, e ele se atualiza sozinho se você editar a spline depois.Dica: para pistas mais complexas (com curvas), você pode ter vários
Polygon Collider 2Dcompostos, ou usar um único collider com múltiplos "paths" (o Polygon Collider 2D do Unity suporta múltiplos contornos no mesmo componente).
Definido: o carro só é considerado "fora da pista" (bateu) quando 0% do seu Collider2D ainda sobrepõe a TrackValidZone — ou seja, 99% fora ainda conta como 100% dentro, e nada acontece enquanto restar qualquer sobreposição. Isso já é o comportamento nativo do OnTriggerExit2D: ele só dispara no instante em que as duas formas deixam de se sobrepor completamente, nunca antes.
Atenção: essa regra depende do Collider2D do
Carcobrir a silhueta real do carro (ex.: umBox Collider 2D/Polygon Collider 2Dajustado ao sprite), não um ponto ou círculo minúsculo no centro — senão "100% fora" na prática vira só "o centro saiu", o que não é a mesma coisa.
3.4 Script: TrackBoundary.cs
Crie em Assets/_Project/Scripts/Track/TrackBoundary.cs e coloque no TrackValidZone:
using UnityEngine;
// POR QUE: detectar o momento em que o carro sai 100% do traçado válido e avisar que ele
// bateu. De quebra, guarda o grip base da pista (é um dado "da pista", então mora aqui).
// ESTRATÉGIA: MonoBehaviour preso ao TrackValidZone (o GameObject com o Polygon Collider 2D
// marcado como Is Trigger). Um TRIGGER não empurra nada fisicamente: ele só avisa, via callbacks
// (OnTriggerEnter2D/Exit2D), quando outro collider entra ou sai dele. Este script não decide o
// que acontece quando o carro bate; ele só avisa o RaceManager, que controla o estado da corrida.
public class TrackBoundary : MonoBehaviour
{
[SerializeField] private float grip = 1f;
// Grip base desta pista inteira (0 a 1): 1.0 no Deserto e na Floresta (fora de
// poça), mais baixo no Gelo — ver Parte 10 §3.8. O CarTerrainSensor (§3.10) lê
// este valor pra saber qual grip aplicar quando o carro não está sobre uma poça.
// "=>" cria uma propriedade só de leitura: outros scripts leem Grip, mas não alteram.
public float Grip => grip;
// OnTriggerExit2D: callback que o Unity chama sozinho quando um collider deixa de
// sobrepor este trigger. Pra ele disparar, pelo menos um dos dois objetos precisa ter
// Rigidbody2D (o Car tem).
// Só dispara quando o Collider2D do carro deixa de sobrepor esta zona por completo
// (0% de sobreposição) — 99% fora ainda conta como dentro, e não gera este evento.
void OnTriggerExit2D(Collider2D other)
{
// CompareTag é a forma recomendada de checar a tag (mais rápida que other.tag == "Car").
// Filtra pra reagir só ao carro, e não a qualquer outro collider que passe por aqui.
if (other.CompareTag("Car"))
{
RaceManager.Instance.OnCarLeftTrack();
}
}
}
3.5 Marcar o carro com a Tag correta
- Selecione o GameObject
Car. - No topo do Inspector, campo
Tag→ Add Tag... → crie a tagCar. - Volte no
Car, selecione a tagCarrecém-criada.
3.6 Script: RaceManager.cs (gerencia o estado da corrida)
Crie em Assets/_Project/Scripts/Track/RaceManager.cs:
using UnityEngine;
using UnityEngine.SceneManagement; // SceneManager: carregar/recarregar cenas
// POR QUE: alguém precisa ser o "juiz" da corrida: contar o tempo, saber se a corrida
// está rolando e se o carro bateu, e reiniciar/voltar ao menu. Sem um dono central, cada
// script (boundary, UI, linha de chegada) teria sua própria cópia desse estado, e as
// cópias acabariam se contradizendo.
// ESTRATÉGIA: MonoBehaviour com padrão SINGLETON: existe exatamente um por cena, acessível
// de qualquer script via RaceManager.Instance, sem precisar arrastar referência no Inspector.
// Os outros scripts AVISAM o RaceManager (OnCarLeftTrack) e LEEM o estado dele (ElapsedTime,
// RaceActive, Crashed); a UI decide o que mostrar olhando esse estado.
public class RaceManager : MonoBehaviour
{
// "static" = pertence à CLASSE, não a um objeto; por isso qualquer script acessa
// RaceManager.Instance direto. private set: só o próprio RaceManager define quem é a instância.
public static RaceManager Instance { get; private set; }
[SerializeField] private float elapsedTime; // tempo da corrida em segundos (visível no Inspector pra depurar)
public float ElapsedTime => elapsedTime;
public bool RaceActive { get; private set; } = true; // o cronômetro está correndo?
public bool Crashed { get; private set; } // true enquanto o menu "Você Bateu" está na tela
// Awake (ver CarController): registra este objeto como a instância única.
// Se já existir outro RaceManager (ex.: duplicado por engano), este se autodestrói.
// Tem que ser no Awake (e não no Start) pra Instance já estar pronta quando outros
// scripts a usarem nos seus Start.
void Awake()
{
// Padrão singleton simples: garante que só existe um RaceManager na cena
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
// Update (ver CarController): soma o tempo do frame ao cronômetro enquanto a corrida está ativa.
// Time.deltaTime = segundos que se passaram desde o frame anterior; somando isso todo frame
// temos o tempo real decorrido, independente do fps.
void Update()
{
if (RaceActive)
{
elapsedTime += Time.deltaTime;
}
}
// Chamado pelo TrackBoundary quando o carro sai da pista.
// Para o cronômetro e entra no estado "bateu"; quem mostra a mensagem é a UI.
public void OnCarLeftTrack()
{
// Se a corrida já não está ativa (já bateu, ou já cruzou a chegada na Fase 5), ignora:
// evita reabrir o menu de colisão, e evita que sair da pista DEPOIS da chegada
// (o carro continua andando enquanto o jogador digita o nome) mostre "Você Bateu".
if (!RaceActive) return;
Debug.Log("Car left the track!");
RaceActive = false;
Crashed = true;
// Não recarrega mais a cena direto — o CrashMenuUI (Fase 4, seção 4.3) mostra
// a mensagem "Você Bateu" e espera o jogador apertar R (RetryRace) ou Esc (QuitToMainMenu).
}
// Recomeça a corrida do zero. Recarregar a cena inteira é a forma mais simples de
// "resetar tudo" (carro, tempo, progresso) sem ter que zerar cada script à mão.
public void RetryRace()
{
// Mesmo efeito que o reinício automático tinha antes: recarrega a cena inteira.
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
// Volta pro menu principal. A cena precisa estar na lista de cenas do build
// (File → Build Profiles / Build Settings), senão o LoadScene dá erro.
public void QuitToMainMenu()
{
SceneManager.LoadScene("MainMenu"); // cena criada na Fase 6 — ver Parte 13 §6.1
}
}
- Crie um GameObject vazio
RaceManagerna Hierarchy e arraste o script nele.
3.7 Progresso da pista (% percorrida)
Objetivo: saber, a qualquer momento da corrida, qual % do traçado o carro já percorreu. Esse número (de 0 a 1) é só dado — quem o mostra na tela é a UI da Fase 4 (Parte 11 §4.5). Aqui a gente só cria o sistema que calcula.
A ideia, em uma frase: espalhamos ao longo da pista uma série de "portas" invisíveis (triggers), numeradas do início ao fim. Cada vez que o carro atravessa uma porta, o progresso sobe para portas atravessadas / total de portas.
LARGADA CHEGADA
| | | | | | | | | | | | (cada "|" = Waypoint_XX, uma porta
00 01 02 03 04 05 06 07 08 09 10 11 invisível atravessando a pista)
carro passou pela porta 02 → progresso = (2 + 1) / 12 = 25%
carro passou pela porta 11 → progresso = 12 / 12 = 100%
Definido: o progresso avança em degraus, um por porta atravessada (não é uma barra perfeitamente suave). É uma aproximação deliberada: suficiente para o indicador da HUD e evita ter que calcular a posição exata do carro projetada na spline da pista.
Se o jogador pular uma porta ou der ré: o sistema guarda o maior número de porta já atravessado. Se o carro pular a porta 04 e passar direto na 05, o progresso vai para 6/12 normalmente. Se o carro voltar e atravessar uma porta antiga de novo, ela é ignorada — o progresso nunca diminui.
1. Posicionar as portas (Waypoint_XX) na cena
- Na Hierarchy, crie um GameObject vazio chamado
TrackWaypoints. Ele é só uma pasta organizadora — todas as portas ficam dentro dele. - Dentro de
TrackWaypoints, crie um GameObject vazio para cada porta, na ordem em que o carro vai passar por elas, da largada até perto da chegada. NomeieWaypoint_00,Waypoint_01,Waypoint_02...- A ordem na Hierarchy importa: o script usa a posição do objeto dentro de
TrackWaypointscomo número da porta (primeiro filho = porta 0, segundo = porta 1, etc.). O nome é só para você se orientar. - Onde colocar: uma boa referência são os pontos de controle da spline da pista (seção 3.1), mas não precisa ser 1 para 1 — o importante é distribuir as portas de forma mais ou menos uniforme ao longo do traçado. Comece com ~10–15 portas por pista.
- Não crie uma porta na linha de chegada — a
FinishLine(Parte 12 §5.6) já cuida disso e força o progresso para 100%.
- A ordem na Hierarchy importa: o script usa a posição do objeto dentro de
- Em cada
Waypoint_XX, adicione um Box Collider 2D comIs Trigger✅ e ajuste-o para ser uma faixa fina atravessando a pista de lado a lado (perpendicular ao sentido da corrida):Size: X = um pouco mais que a largura da pista naquele ponto, Y = algo fino (ex.:0.5).- Gire o GameObject (
Rotation Z) para que a faixa fique "de través" na pista, como uma linha de chegada. - Pense nela como uma catraca: como cobre a pista toda, o carro não tem como seguir em frente sem tocá-la.
Dica: com Gizmos ligado na Scene view, os colliders aparecem como caixas verdes — fica fácil conferir se cada porta cobre a largura toda da pista.
2. Script: TrackWaypoint.cs (vai em cada porta)
Crie em Assets/_Project/Scripts/Track/TrackWaypoint.cs:
using UnityEngine;
// POR QUE: cada "porta" invisível da pista precisa avisar quando o carro passa por ela,
// pra podermos calcular o % percorrido.
// ESTRATÉGIA: script minúsculo, repetido em cada Waypoint_XX. Ele não faz conta nenhuma:
// só descobre o próprio número (pela ordem na Hierarchy) e avisa o TrackProgress, que é
// quem guarda o progresso. Assim a lógica fica num lugar só e as portas são "burras".
public class TrackWaypoint : MonoBehaviour
{
// O número da porta é a posição dela dentro de TrackWaypoints na Hierarchy
// (primeiro filho = 0, segundo = 1, ...). Nada para preencher no Inspector.
// GetSiblingIndex() = posição deste objeto entre os "irmãos" dentro do mesmo pai.
public int Index => transform.GetSiblingIndex();
// OnTriggerEnter2D: callback do Unity quando um collider ENTRA neste trigger
// (é o par do OnTriggerExit2D, ver TrackBoundary).
void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Car")) // tag criada na seção 3.5
TrackProgress.Instance.OnWaypointReached(Index);
}
}
Selecione todos os Waypoint_XX de uma vez (clique no primeiro, Shift+clique no último) e adicione o script pelo Inspector — ele entra em todos ao mesmo tempo. Não há nenhum campo para preencher.
3. Script: TrackProgress.cs (um só na cena — é quem faz a conta)
Crie em Assets/_Project/Scripts/Track/TrackProgress.cs:
using UnityEngine;
// POR QUE: transformar "o carro passou pela porta N" em "o carro percorreu X% da pista",
// um número que a HUD (Fase 4 §4.5) pode mostrar.
// ESTRATÉGIA: singleton (mesmo padrão do RaceManager) que recebe avisos das portas e guarda
// só o MAIOR índice já atravessado; assim, dar ré ou repetir uma porta nunca faz o % cair.
// Conta sozinho quantas portas existem, pra você não ter que digitar esse número em cada pista.
public class TrackProgress : MonoBehaviour
{
public static TrackProgress Instance { get; private set; }
public float ProgressNormalized { get; private set; } // 0 = largada, 1 = chegada
private int totalWaypoints; // contado sozinho no Start, a partir das portas da cena
private int lastWaypointIndex = -1; // maior porta já atravessada (-1 = nenhuma ainda)
// Awake: registra o singleton (mesmo padrão do RaceManager).
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
}
// Start: o Unity chama uma vez, depois que TODOS os Awake da cena já rodaram e logo
// antes do primeiro Update. É um bom lugar pra coisas que dependem de outros objetos
// já estarem prontos; aqui, contar as portas.
void Start()
{
// Conta quantas portas (TrackWaypoint) existem na cena — não precisa digitar à mão.
// FindObjectsByType varre a cena inteira (é lento), por isso roda uma vez só, no Start.
totalWaypoints = FindObjectsByType<TrackWaypoint>(FindObjectsSortMode.None).Length;
if (totalWaypoints == 0)
Debug.LogWarning("TrackProgress: nenhum TrackWaypoint na cena — o % vai ficar em 0 até a chegada.");
}
// Chamado por cada TrackWaypoint quando o carro atravessa. Atualiza o % se a porta for nova.
public void OnWaypointReached(int index)
{
if (index <= lastWaypointIndex) return; // porta repetida ou antiga (ré): ignora
lastWaypointIndex = index;
// O (float) evita divisão inteira: em C#, 3 / 12 com dois int dá 0, e não 0.25.
ProgressNormalized = (index + 1) / (float)totalWaypoints;
}
// Força 100%. Usado na linha de chegada, que não tem porta própria.
public void ForceComplete()
{
ProgressNormalized = 1f; // chamado pelo RaceManager.OnRaceFinished() (Parte 12 §5.6)
}
}
- Crie um GameObject vazio
TrackProgressna Hierarchy (fora deTrackWaypoints) e arraste o script nele. Nada para configurar no Inspector.
4. Garantir 100% ao cruzar a chegada (fazer só na Fase 5)
Como não existe porta na linha de chegada, quem leva o progresso a 100% é o RaceManager. Quando chegar na Parte 12 §5.6, adicione a chamada ForceComplete() dentro de OnRaceFinished(), logo após Finished = true;:
// Trecho do RaceManager (versão da Parte 12 §5.6). O que muda: a linha ForceComplete().
// Por quê: não existe porta na linha de chegada, então sem essa linha o % pararia na
// última porta (ex.: 92%). O RaceManager é quem sabe que a corrida acabou, então é ele quem avisa.
public void OnRaceFinished()
{
if (!RaceActive) return;
RaceActive = false;
Finished = true;
TrackProgress.Instance.ForceComplete(); // garante 100% mesmo que o último waypoint tenha sido pulado
Debug.Log($"Race finished! Time: {ElapsedTime}");
}
Até a Fase 5 existir, o progresso para no valor da última porta (ex.:
92%) — é esperado.
5. Como testar agora (antes da UI da Fase 4)
Temporariamente, adicione no fim de OnWaypointReached a linha Debug.Log($"Porta {index} → {ProgressNormalized:P0}");, dê Play e dirija pela pista. O Console deve mostrar Porta 0 → 8 %, Porta 1 → 17 %... subindo em ordem. Se alguma porta não aparecer, o collider dela não cobre a largura da pista (ou está sem Is Trigger). Remova o log depois.
Em aberto: o número de waypoints por pista (e a distância entre eles) ainda não está calibrado — poucos waypoints deixam o indicador "pulando" em saltos grandes; waypoints demais dão mais trabalho manual ao montar cada pista. Ajustar depois de testar em pista real.
3.8 Grip por pista
Objetivo: dar ao
TrackValidZonede cada pista o grip base que a caracteriza (ver Parte 2 §"Pistas"). Isso já é lido peloCarController(Fase 1 §1.2) através do sensor que a seção 3.10 cria — aqui só configuramos o dado.
No componente TrackBoundary (seção 3.4) de cada cena, ajuste o campo Grip no Inspector:
| Pista | Grip base do TrackValidZone |
|---|---|
| Deserto | 1.0 (padrão, sem modificador) |
| Floresta | 1.0 fora de poça — a poça em si tem seu próprio grip menor (seção 3.9) |
| Gelo | baixo (ex.: 0.3 — placeholder, Em aberto: calibrar em prototipagem) |
Definido: o Gelo usa um grip uniforme mais baixo que o de qualquer poça da Floresta — é castigo constante, não pontual (ver Parte 2 §"Pistas").
3.9 Poças de água (Floresta)
Objetivo: zonas de trigger menores, com grip mais baixo que o resto da pista, espalhadas em posições fixas pelo traçado da Floresta (não sorteadas — ver Parte 2 §"Pistas"). Só existem na cena
RaceForest.
1. Script: PuddleZone.cs
Crie em Assets/_Project/Scripts/Track/PuddleZone.cs:
using UnityEngine;
// POR QUE: marcar uma área da pista como "poça" e dizer quanto grip ela tem.
// ESTRATÉGIA: é só um "rótulo com dado", sem nenhum Update ou lógica. Fica no GameObject
// da poça (junto do collider trigger) e quem faz o trabalho é o CarTerrainSensor do carro:
// quando ele encosta num trigger, pergunta "você tem um PuddleZone?" e lê o Grip daqui.
// Separar assim deixa cada poça configurável no Inspector (uma pode escorregar mais que outra).
public class PuddleZone : MonoBehaviour
{
[SerializeField] private float grip = 0.5f; // Em aberto: valor exato a calibrar em prototipagem
public float Grip => grip; // só leitura pra outros scripts (ver TrackBoundary)
}
2. Criar as poças na cena RaceForest:
- Escolha manualmente alguns pontos ao longo do traçado (2-4 pra começar, ajustar depois de testar).
- Em cada um, crie um GameObject
Puddle_00,Puddle_01, etc., dentro de uma pasta organizadoraPuddles(igual ao padrão deTrackWaypoints, seção 3.7). - Adicione um Circle Collider 2D (ou
Box Collider 2D) comIs Trigger✅, do tamanho de uma poça — bem menor que a largura total da pista, pra dar espaço pro jogador desviar. - Arraste o script
PuddleZone.cs. - Adicione um sprite simples de poça (
SpriteRenderer, textura escura/reflexiva) do mesmo tamanho do collider, só pra leitura visual — sem ele, o jogador não teria como prever onde vai escorregar.
Dica: como as poças são fixas e visíveis, elas fazem parte do "traçado de risco" que o jogador aprende a decorar tentando bater o próprio recorde — coerente com o pilar de contrarrelógio da Parte 2.
3.10 Sensor de terreno no carro
Objetivo: ler o grip da
TrackValidZone(seção 3.8) e dasPuddleZoneque o carro estiver sobrepondo (seção 3.9), e escrever o resultado emCarController.CurrentGrip/InPuddle(Fase 1 §1.2) — é a ponte entre o dado de terreno e a física do carro.
Definido: a detecção acontece no próprio Car (não na zona), porque o carro pode estar sobre 0, 1 ou mais poças ao mesmo tempo (poças sobrepostas) — contar overlaps no carro é mais simples que coordenar várias zonas entre si.
Crie em Assets/_Project/Scripts/Car/CarTerrainSensor.cs:
using UnityEngine;
// POR QUE: é a ponte entre "sobre qual terreno o carro está" e "quanto ele derrapa".
// O CarController não sabe nada de pistas ou poças: ele só usa o CurrentGrip que alguém
// escreve nele. Este sensor é esse "alguém".
// ESTRATÉGIA: MonoBehaviour no próprio Car. Como o carro tem Collider2D + Rigidbody2D, ele
// recebe OnTriggerEnter2D/Exit2D de QUALQUER trigger que tocar (poças, portas, TrackValidZone...).
// O sensor filtra só os que têm PuddleZone e CONTA quantas poças está sobrepondo (pode ser mais
// de uma ao mesmo tempo). Enquanto a contagem for > 0, usa o grip da poça; quando volta a 0,
// devolve o grip base da pista (lido do TrackBoundary).
public class CarTerrainSensor : MonoBehaviour
{
[SerializeField] private CarController car; // onde escrevemos CurrentGrip e InPuddle
[SerializeField] private TrackBoundary trackBoundary; // fornece o grip base da pista atual (seção 3.8)
private int puddleOverlapCount; // em quantas poças o carro está AGORA (poças podem se sobrepor)
private float currentPuddleGrip; // grip em uso enquanto estiver em poça (o menor entre as sobrepostas)
// Start (ver TrackProgress): aplica o grip base da pista assim que a corrida começa.
// É isso que faz o Gelo escorregar o traçado inteiro sem precisar de nenhuma poça.
void Start()
{
car.CurrentGrip = trackBoundary.Grip;
}
// Callback de trigger (ver TrackWaypoint): o carro encostou em algum trigger.
// Se for uma poça, soma na contagem e aplica o grip dela.
void OnTriggerEnter2D(Collider2D other)
{
// GetComponent no OUTRO objeto: "esse trigger tem um PuddleZone?". Se não tiver
// (é uma porta, a zona da pista etc.), ignoramos. Aqui não dá pra cachear, porque
// cada evento traz um objeto diferente; mas o evento é raro, então o custo é baixo.
var puddle = other.GetComponent<PuddleZone>();
if (puddle == null) return;
puddleOverlapCount++;
// Se o carro estiver sobre mais de uma poça ao mesmo tempo, usa a mais escorregadia
currentPuddleGrip = puddleOverlapCount == 1 ? puddle.Grip : Mathf.Min(currentPuddleGrip, puddle.Grip);
car.CurrentGrip = currentPuddleGrip;
car.InPuddle = true;
}
// O carro saiu de algum trigger. Se era poça, desconta; se não sobrou nenhuma,
// volta ao grip base da pista.
void OnTriggerExit2D(Collider2D other)
{
var puddle = other.GetComponent<PuddleZone>();
if (puddle == null) return;
// Mathf.Max(0, ...) é uma proteção: a contagem nunca fica negativa, mesmo se
// algum evento vier fora de ordem.
puddleOverlapCount = Mathf.Max(0, puddleOverlapCount - 1);
if (puddleOverlapCount == 0)
{
car.CurrentGrip = trackBoundary.Grip;
car.InPuddle = false;
}
// Simplificação: se ainda sobrar alguma poça, o grip continua no menor valor já
// aplicado (não recalculamos qual poça ficou). Com poças de grip parecido, isso
// não faz diferença perceptível.
}
}
- Arraste o script no GameObject
Car(ele precisa do próprioCollider2D/Rigidbody2Ddo carro pra receber os eventos de trigger — já existem desde a Fase 1). - No Inspector, ligue
Cara ele mesmo eTrack Boundaryao GameObjectTrackValidZoneda cena.
Nas cenas
RaceDeserteRaceIce(sem poças), este script simplesmente nunca recebeOnTriggerEnter2Dde umaPuddleZone— o carro fica sempre com o grip base da pista, lido uma vez emStart(). Nada precisa ser desligado/condicionado por pista.
3.11 Chuva ambiente (Floresta)
Objetivo: um efeito puramente visual de chuva caindo, só na cena
RaceForest— não afeta a física, é só ambientação que reforça a razão das poças existirem.
- Crie um Particle System como filho da
Main Camera(Create Emptydentro dela → Add Component → Particle System), renomeieRainAmbient. - Configure:
Shape = Box, alinhada horizontalmente acima da área visível da câmera;Start Speedalta eGravity Modifierpositivo pra cair rápido de cima pra baixo;Start Lifetimecurto (só o suficiente pra atravessar a tela);Rate over Timealto (chuva densa); sprite/textura fina (uma linha ou traço, não um círculo). - Por estar dentro da
Main Camera, a chuva sempre cobre a área visível, mesmo se a câmera seguir o carro. - Marque
Play On Awake✅ — a chuva começa a cair assim que a cena carrega, sem precisar de script.
Não precisa de script próprio: é configuração pura de Inspector.
3.12 Salvando como template e criando as pistas Floresta e Gelo
Objetivo: depois que a
RaceDesertestiver completa (Fases 1 a 4: carro, partículas, chevrons, pista, timer/UI funcionando), reaproveitar tudo que é comum pra criar as outras duas pistas sem refazer carro/chevrons/RaceManager/UI do zero.
Definido: usamos o recurso nativo de Scene Template do Unity — ele existe exatamente pra isso.
- Com a
RaceDesertcompleta e salva, clique com o botão direito nela na janela Project → Create → Scene Template From Scene. Isso gera um asset.scenetemplate; mova/renomeie praAssets/_Project/SceneTemplates/RaceTrack.scenetemplate. - Pra criar uma pista nova:
File → New Scene, escolha a aba Scene Templates e selecioneRaceTrack— isso abre uma cópia completa daRaceDesert(carro, chevrons, RaceManager, TrackProgress, Canvas/UI, câmera, e também a pista do Deserto em si). - Salve essa cópia como
RaceForest(ouRaceIce) emAssets/_Project/Scenes/. - Dentro da cópia, apague os objetos específicos do Deserto —
Track_Desert(Sprite Shape),Tilemap_Terrain,TrackWaypoints— e refaça-os do zero seguindo as seções 3.1–3.2 e 3.7 com a geometria/terreno daquela pista. OTrackValidZone(com oTrackBoundary, seção 3.4) também é redesenhado (o polígono/spline muda de forma), mas o script continua o mesmo — só ajuste o campoGrip(seção 3.8) pro valor daquela pista (o total de waypoints é contado sozinho peloTrackProgress, seção 3.7). - Só na
RaceForest: adicione asPuddleZone(seção 3.9), o preset de partícula de respingo (Fase 1 §1.5) e a chuva ambiente (seção 3.11). - Só na
RaceIce: troque o preset de partícula doCarTrailParticlespra gelo/neve (Fase 1 §1.5) — sem poça, sem chuva. - Tudo que não foi apagado (
Car,ChevronGuide,RaceManager,TrackProgress,Canvascom toda a UI, câmera) já está funcionando igual àRaceDesert— nenhuma referência de script quebra, porque tudo se encontra por singleton (RaceManager.Instance,TrackProgress.Instance) ou por arrasto no Inspector dentro da própria cena copiada.
Em aberto: a Noturna (pós-MVP, Parte 2 §"Pistas") segue o mesmo pipeline quando entrar em produção — mesmo template, mais uma entrada em
MainMenuController.tracks(Parte 13 §6.6) com seu próprioBackgroundSprite.
✅ Checkpoint da Fase 3
- Dar Play, guiar o carro para fora dos limites do
TrackValidZone→RaceActivevirafalseeCrashedviratrue(visível no Inspector, já que são propriedades sem UI própria ainda — a UI do menu "Você Bateu" só é ligada na Fase 4, seção 4.3). - Guiar o carro dentro da pista normalmente → nada acontece, corrida continua.
- Guiar o carro através dos
Waypoint_XXem ordem →TrackProgress.Instance.ProgressNormalizedsobe em degraus no Inspector (visível ao selecionar o GameObjectTrackProgressdurante o Play). - Na
RaceDesert,CarController.CurrentGripfica sempre em1durante o Play (visível no Inspector, componenteCar). - Depois da seção 3.12: dar Play na
RaceForeste cruzar uma poça →CurrentGripcai pro valor daPuddleZone,InPuddleviratrue, e o carro visivelmente "escorrega" antes de voltar a obedecer o cursor ao sair dela; naRaceIce,CurrentGripfica sempre no valor baixo configurado na seção 3.8, o traçado inteiro.
Problemas comuns
OnTriggerExit2Dnunca dispara: confirme que oCartem um Collider2D (não só o Rigidbody2D) e que pelo menos um dos dois colliders envolvidos (carro ou zona) não está marcado como trigger simultaneamente de forma que ambos sejam triggers — Unity exige que pelo menos um Rigidbody2D esteja envolvido para eventos de trigger funcionarem (o do carro já resolve isso).- Carro "atravessa" a borda sem detectar: se o carro estiver muito rápido, ative
Collision Detection = Continuousno Rigidbody2D do carro (Rigidbody2D → Collision Detection). ProgressNormalizednunca passa de um certo valor: confira se osWaypoint_XXestão na ordem do sentido da corrida dentro deTrackWaypointsna Hierarchy (oIndexvem dessa ordem, viaGetSiblingIndex()) — uma porta fora de ordem é ignorada pelo guardif (index <= lastWaypointIndex).- Carro nunca parece derrapar na Floresta/Gelo: confira se
CarTerrainSensorestá noCar(não na zona) e se o campoTrack Boundaryestá ligado aoTrackValidZoneda cena — sem isso,CurrentGripnunca é setado e fica no valor padrão (1) doCarController.
Próxima fase: 04_TIMER_UI.md — cronômetro visível na tela e feedback de UI.
Fase 4 — Timer e UI
Objetivo: mostrar o cronômetro na tela em tempo real, formatado como
00:00.00.
4.1 Criar o Canvas e o texto
- Hierarchy → botão direito → UI → Canvas (Unity cria automaticamente um
EventSystemjunto — deixe como está). - No
Canvas, ajusteCanvas Scaler → UI Scale Modepara Scale With Screen Size, comReference Resolutiontipo1920 x 1080(evita a UI ficar em tamanhos diferentes em telas diferentes). - Dentro do Canvas, botão direito → UI → Text - TextMeshPro (se for a primeira vez, aceite importar o "TMP Essentials").
- Renomeie para
TimerText. - Posicione no canto superior (ex.:
Anchor= top-center,Pos Yperto do topo). - Ajuste
Font Size(ex.: 48) e alinhamento central.
4.2 Script: TimerDisplay.cs
Crie em Assets/_Project/Scripts/UI/TimerDisplay.cs:
using UnityEngine;
using TMPro; // TextMeshPro: o sistema de texto recomendado do Unity (TMP_Text)
// POR QUE: mostrar na tela o tempo que o RaceManager está contando.
// ESTRATÉGIA: separar "contar" (RaceManager) de "mostrar" (este script). O RaceManager nem
// sabe que existe UI. Isso deixa trocar o visual do cronômetro sem mexer na lógica da corrida,
// e o mesmo tempo pode ser mostrado em vários lugares (HUD, tela de chegada...).
public class TimerDisplay : MonoBehaviour
{
[SerializeField] private TMP_Text timerText; // o texto na tela que vamos reescrever todo frame
// Update (ver CarController): todo frame, lê o tempo e formata como mm:ss.cc.
void Update()
{
float time = RaceManager.Instance.ElapsedTime;
int minutes = Mathf.FloorToInt(time / 60f);
int seconds = Mathf.FloorToInt(time % 60f); // % = resto da divisão: segundos dentro do minuto
int centiseconds = Mathf.FloorToInt((time * 100f) % 100f); // centésimos de segundo
// $"..." = string interpolada; ":00" formata com 2 dígitos (7 vira "07").
timerText.text = $"{minutes:00}:{seconds:00}.{centiseconds:00}";
}
}
- Arraste o script no
TimerText(ou num objeto separado, se preferir organizar assim). - No Inspector, arraste o próprio componente
TMP_TextdoTimerTextpro campoTimer Text.
4.3 Menu de colisão: "Você Bateu"
Objetivo: quando
RaceManager.Crashedviratrue(Fase 3, seção 3.6), pausar a corrida e mostrar um menu esperando o jogador apertarR(tentar de novo) ouEsc(voltar ao grid de pistas) — ver decisão de design na Parte 2 §"Menus de Feedback (Colisão e Chegada)".
1. Criar o painel na UI:
- No mesmo
Canvasda seção 4.1, crie um painelCrashPanel(UI → Panel). - Dentro dele, um texto TextMeshPro
CrashMessageTextcom o conteúdo:Você Bateu!\nAperte ESC para voltar ao grid ou R para tentar novamente. - Deixe
CrashPaneldesativado por padrão (SetActive(false)no Inspector) — o script abaixo liga/desliga ele sozinho.
2. Script: CrashMenuUI.cs
Crie em Assets/_Project/Scripts/UI/CrashMenuUI.cs:
using UnityEngine;
using UnityEngine.InputSystem; // Keyboard.current
// POR QUE: quando o carro bate, o jogador precisa ver a mensagem "Você Bateu" e escolher
// entre tentar de novo (R) ou voltar ao menu (Esc).
// ESTRATÉGIA: a UI só OBSERVA o RaceManager (lê Crashed) e PEDE a ele as ações (RetryRace,
// QuitToMainMenu). Ela não guarda estado próprio: o painel aparece se, e só se, Crashed for true.
// Importante: este script precisa estar num objeto ATIVO (ex.: o Canvas). Update não roda em
// objeto desativado, então se o script estiver no próprio CrashPanel (que começa desligado),
// ele nunca conseguiria ligar o painel.
public class CrashMenuUI : MonoBehaviour
{
[SerializeField] private GameObject crashPanel; // o painel da mensagem, que começa desativado
// Update (ver CarController): sincroniza o painel com o estado Crashed e, enquanto
// ele estiver aberto, escuta as teclas R/Esc.
void Update()
{
bool crashed = RaceManager.Instance.Crashed;
// activeSelf = "este objeto está ligado?". Só chama SetActive quando muda.
if (crashPanel.activeSelf != crashed)
crashPanel.SetActive(crashed);
if (!crashed) return; // fora do menu de batida, ignora teclado
// wasPressedThisFrame = true só no frame em que a tecla desceu (não fica repetindo
// enquanto ela estiver segurada).
if (Keyboard.current.rKey.wasPressedThisFrame)
RaceManager.Instance.RetryRace();
else if (Keyboard.current.escapeKey.wasPressedThisFrame)
RaceManager.Instance.QuitToMainMenu();
}
}
- Arraste o script num GameObject ativo da cena (ex.: no
Canvas) — não no próprioCrashPanel: ele começa desativado, e um script em objeto desativado não rodaUpdate, então o painel nunca apareceria. - No Inspector, ligue o campo
Crash Panelao GameObjectCrashPanelcriado acima.
QuitToMainMenu()carrega a cenaMainMenu, que só é criada na Fase 6 (Parte 13). Até lá, testar oEscnesta fase vai gerar um erro de "cena não encontrada nos Build Settings" — normal nesse ponto do guia, não é um bug.
4.4 Indicador de velocidade
Objetivo: mostrar a velocidade real do carro (pós-grip, não a pedida pelo cursor) como uma barra leve em %, sem virar um velocímetro numérico pesado (ver pilar na Parte 1 §1, e a distinção intenção/realidade na Parte 1 §3). A fonte do dado já existe:
CarController.ActualSpeedNormalized(Fase 1, seção 1.2), que varia de 0 a 1 e reflete o grip do terreno atual.
1. Importar os sprites da barra:
Os sprites estão em Sprites/Rally2D/ui/, desenhados no estilo flat da franquia:
| Arquivo | Tamanho | O que é |
|---|---|---|
speedbar_frame.png | 640 × 112 | Moldura: contorno marrom, trilho escuro e sombra. Fica atrás. |
speedbar_fill.png | 600 × 64 | Preenchimento em degradê verde → amarelo → laranja → vermelho. Como o Filled corta da esquerda para a direita, o vermelho só aparece com o carro rápido. |
speedbar_fill_white.png | 600 × 64 | O mesmo preenchimento em branco, caso prefira uma cor só, definida pelo campo Color da Image. |
- Copie os três para
Assets/_Project/Sprites/UI/. - Selecione os três e, no Inspector, use
Texture Type = Sprite (2D and UI)e clique em Apply. Por ser arte vetorial e não pixel art, deixeFilter Mode = Bilinear.
2. Montar a barra na UI:
-
No mesmo
Canvasda seção 4.1, crie umaImagechamadaSpeedBarBackground(UI → Image).Source Image = speedbar_frame, depois clique em Set Native Size.- Posicione num canto discreto da tela (ex.: ancorada no canto inferior direito). Não é o foco visual do HUD, é um apoio.
- Para mudar o tamanho, altere
WidtheHeightmantendo a proporção 640 × 112 (ex.: 320 × 56).
-
Como filha dela, crie outra
ImagechamadaSpeedBarFill.Source Image = speedbar_fill.Image Type = Filled,Fill Method = Horizontal,Fill Origin = Left.- Deixe
Fill Amount = 1por enquanto, para ver a barra cheia enquanto ajusta.
-
Encaixe o preenchimento no trilho pelas âncoras do
RectTransformdoSpeedBarFill:Anchors Min = (0.03125, 0.25)eAnchors Max = (0.96875, 0.8214);Left,Top,RighteBottomtodos em0.
As âncoras são frações da moldura, então o encaixe vale em qualquer tamanho da barra. Os números vêm da posição do preenchimento na moldura: 20 px de cada lado, 20 px em cima e 28 px embaixo, por causa da sombra.
3. Script: SpeedDisplay.cs
Crie em Assets/_Project/Scripts/UI/SpeedDisplay.cs:
using UnityEngine;
using UnityEngine.UI; // Image (componente de UI)
// POR QUE: mostrar a velocidade REAL do carro como uma barra discreta, sem virar velocímetro numérico.
// ESTRATÉGIA: mesmo padrão do TimerDisplay: o CarController calcula e este script só desenha.
// Usa uma Image com Image Type = Filled, que tem o campo fillAmount (0 = vazia, 1 = cheia).
// Como ActualSpeedNormalized já vem de 0 a 1, basta copiar um valor no outro.
public class SpeedDisplay : MonoBehaviour
{
[SerializeField] private CarController car;
[SerializeField] private Image speedFillImage; // Image Type = Filled
// Update (ver CarController): copia a velocidade real (pós-grip) para a barra, todo frame.
void Update()
{
speedFillImage.fillAmount = car.ActualSpeedNormalized;
}
}
- Arraste o script em qualquer GameObject da cena (ex.: no próprio
SpeedBarFill). - No Inspector, ligue
Carao GameObjectCarda cena eSpeed Fill Imageao componenteImagedoSpeedBarFill.
4.5 Indicador de % da pista percorrida
Objetivo: mostrar quanto da pista o jogador já percorreu, lendo o
TrackProgresscriado na Fase 3 (Parte 10 §3.7).
1. Criar o texto na UI:
- No mesmo
Canvas, crie um texto TextMeshProTrackProgressText(posição sugerida: ao lado doTimerText, seção 4.1).
2. Script: TrackProgressDisplay.cs
Crie em Assets/_Project/Scripts/UI/TrackProgressDisplay.cs:
using UnityEngine;
using TMPro;
// POR QUE: mostrar ao jogador quanto da pista ele já percorreu (ex.: "42%").
// ESTRATÉGIA: mesmo padrão "lógica calcula, UI mostra" (ver TimerDisplay): o TrackProgress
// calcula o valor de 0 a 1, e este script só converte pra porcentagem e escreve no texto.
public class TrackProgressDisplay : MonoBehaviour
{
[SerializeField] private TMP_Text progressText;
// Update (ver CarController): converte 0..1 em 0..100 (arredondado) e mostra com "%".
void Update()
{
int percent = Mathf.RoundToInt(TrackProgress.Instance.ProgressNormalized * 100f);
progressText.text = $"{percent}%";
}
}
- Arraste o script no
TrackProgressText(ou num objeto separado). - No Inspector, ligue
Progress Textao próprio componenteTMP_TextdoTrackProgressText.
✅ Checkpoint da Fase 4
- Dar Play → o cronômetro aparece no topo da tela e conta corretamente (
00:00.00,00:01.23, etc.). - Acelerar/frear → a
SpeedBarFillsobe e desce acompanhandoActualSpeedNormalized(0–100%). NaRaceDesertisso acompanha o cursor de perto; naRaceForest/RaceIce(Fase 3, §3.8-3.10) a barra visivelmente "atrasa" em relação ao pedido do cursor ao escorregar. - Guiar o carro pelos
Waypoint_XX(Fase 3, seção 3.7) →TrackProgressTextsobe em degraus (ex.:0%→8%→16%...). - Guiar o carro pra fora da pista → o cronômetro para e o
CrashPanelaparece com a mensagem "Você Bateu! Aperte ESC para voltar ao grid ou R para tentar novamente.". - Apertar
Rcom o menu aberto → a pista recarrega do zero (mesmo efeito do reinício automático antigo). - Apertar
Esccom o menu aberto → tenta carregarMainMenu(só funciona de fato a partir da Fase 6, ver nota acima).
Problemas comuns
SpeedBarFillnão se move: confira seImage Type = Filledestá marcado — numaImagenormal,fillAmountnão tem efeito visual.- O preenchimento sai do trilho ou fica torto: confira as âncoras do
SpeedBarFill(seção 4.4, passo 2.3). Os quatro offsets (Left,Top,Right,Bottom) precisam estar em0, e a proporção daSpeedBarBackgroundprecisa continuar 640 × 112. TrackProgressTextfica sempre em0%: confira se osWaypoint_XXtêm o scriptTrackWaypointe umBox Collider 2DcomIs Trigger✅ cobrindo a largura da pista, e se o carro tem a tagCar(Fase 3, seções 3.5 e 3.7). Se o % sobe fora de ordem, a ordem dos filhos dentro deTrackWaypointsna Hierarchy não segue o sentido da corrida.
Próxima fase: 05_FIREBASE_LEADERBOARD.md — conectar o projeto ao Firebase e enviar/ler tempos do leaderboard global.
Fase 5 — Firebase: Leaderboard Global
Objetivo: enviar o tempo do jogador para o Firestore ao final de uma corrida válida, e exibir o ranking dos melhores tempos da pista.
Nota de plataforma: build WebGL para itch.io
Definido: o alvo de build para o primeiro lançamento é WebGL, publicado no itch.io, não Android. O motivo é prático: o suporte a build Android no Unity exige instalar módulos adicionais (Android SDK/NDK/JDK via Unity Hub) que precisam de permissão de administrador na máquina, indisponível no momento. WebGL não tem essa dependência — o módulo WebGL Build Support se instala pelo Unity Hub sem precisar de ferramentas externas de outro fabricante.
Isso tem uma implicação direta na integração com Firebase: o Firebase Unity SDK oficial (o pacote .unitypackage de Auth/Firestore usado nas seções 5.3–5.5 abaixo) não suporta o build target WebGL — ele depende de bibliotecas nativas compiladas para Android/iOS/Desktop, que não existem/não linkam em WebGL. Importar esse SDK e tentar buildar para WebGL resulta em erros de compilação ou DllNotFoundException em runtime.
A solução usada por esta seção é não importar o SDK nativo por enquanto e falar direto com o Firebase via REST API, usando UnityWebRequest (que funciona normalmente em WebGL, dentro do navegador). As seções 5.3-WEB / 5.4-WEB / 5.5-WEB abaixo documentam esse caminho — é o que você deve seguir agora. As seções 5.3/5.4/5.5 "SDK nativo" originais ficam documentadas para quando o suporte a build Android/iOS for viável (outra máquina, ou resolvendo a permissão de admin) — em aberto quando isso acontecer.
5.1 Criar o projeto no Firebase Console
- Acesse console.firebase.google.com e faça login com sua conta Google.
- Add project → nome
RallySurvive→ siga o assistente (pode desativar Google Analytics por enquanto, não é necessário pro MVP). - Dentro do projeto, vá em Build → Firestore Database → Create database → escolha modo production (vamos configurar regras de segurança manualmente) → escolha a região mais próxima do seu público.
- Ainda no console, vá em Build → Authentication → Get started → Sign-in method e habilite o provedor Anonymous. Sem isso, o login anônimo (seções 5.4 e 5.4-WEB) falha com erro 400 (
ADMIN_ONLY_OPERATIONna versão REST).
5.2 Registrar o app no Firebase
- No console, ícone de engrenagem → Project settings → aba General → em "Your apps", clique no ícone
</>(Web) — é essa a plataforma relevante agora, já que o alvo é WebGL/itch.io. - Dê um apelido ao app (ex.:
RallySurvive Web) e finalize o cadastro. Não é preciso hospedar no Firebase Hosting — só precisamos das credenciais. - O console vai mostrar um objeto
firebaseConfigcom campos comoapiKeyeprojectId. Guarde esses dois valores — são o que a seção 5.3-WEB usa para falar com o Firebase via REST (nenhum SDK JS embutido é necessário). - Se um dia for buildar para Android/iOS também, volte aqui e registre esses apps adicionalmente (ícone Android/iOS) — vai gerar
google-services.json/GoogleService-Info.plist, usados apenas pela seção 5.3 "SDK nativo" abaixo.
5.3 (Fase futura — Android/iOS/Desktop) Importar o Firebase Unity SDK
Em aberto: esta seção fica registrada para quando for possível instalar suporte a build Android/iOS. Não é necessária para o build WebGL atual — pule direto para 5.3-WEB.
- Baixe o Firebase Unity SDK em firebase.google.com/download/unity.
- No Unity,
Assets → Import Package → Custom Package, selecione o.unitypackagede Firestore (dentro do SDK baixado — vem separado por produto: Auth, Firestore, Analytics, etc.). - Importe também o pacote de Authentication (vamos usar login anônimo, mais simples pra MVP).
- Coloque o
google-services.json(ou.plist) na raiz deAssets/(o SDK vai processá-lo automaticamente no build). - O Unity vai pedir pra resolver dependências automaticamente (Assets → External Dependency Manager → Android Resolver → Resolve, se estiver no Android). Aceite e espere terminar (pode demorar alguns minutos).
5.4 (Fase futura — Android/iOS/Desktop) Script: FirebaseBootstrap.cs (inicialização)
Em aberto, mesma ressalva da 5.3 — não usar agora.
Crie em Assets/_Project/Scripts/Firebase/FirebaseBootstrap.cs:
using UnityEngine;
using Firebase;
using Firebase.Auth;
using Firebase.Firestore;
// POR QUE: antes de ler ou gravar qualquer tempo no leaderboard, o Firebase precisa ser
// inicializado e o jogador precisa estar logado (anonimamente). Isso tem que acontecer
// uma única vez por sessão de jogo, antes de qualquer corrida.
// ESTRATÉGIA (versão SDK nativo, fase futura): MonoBehaviour que vive na primeira cena e
// sobrevive às trocas de cena (DontDestroyOnLoad). Expõe o banco (Db), o login (Auth) e um
// sinal IsReady em propriedades STATIC, pra qualquer script usar sem precisar de referência
// no Inspector. O LeaderboardService checa IsReady antes de tudo.
public class FirebaseBootstrap : MonoBehaviour
{
public static FirebaseFirestore Db { get; private set; } // acesso ao banco Firestore
public static FirebaseAuth Auth { get; private set; } // acesso ao login (pra pegar o UID)
public static bool IsReady { get; private set; } // true só depois de inicializar E logar
// "async void Awake": Awake normal (ver CarController), mas marcado como async pra poder
// usar "await". await = "espere essa operação lenta (rede, disco) terminar SEM travar o
// jogo; quando ela acabar, continue daqui". Enquanto isso o Unity segue desenhando frames.
// (async void só é aceitável em callbacks do Unity como este; nos seus métodos, use async Task.)
async void Awake()
{
// DontDestroyOnLoad: este objeto NÃO é destruído quando outra cena carrega.
// Assim o login feito no menu continua valendo nas pistas.
DontDestroyOnLoad(gameObject);
// Confere se as bibliotecas nativas do Firebase estão presentes/atualizadas no aparelho.
var dependencyStatus = await FirebaseApp.CheckAndFixDependenciesAsync();
if (dependencyStatus != DependencyStatus.Available)
{
Debug.LogError($"Firebase not available: {dependencyStatus}");
return; // IsReady continua false → o leaderboard simplesmente não faz nada
}
Auth = FirebaseAuth.DefaultInstance;
Db = FirebaseFirestore.DefaultInstance;
// Login anônimo: cada jogador ganha um UID único sem precisar criar conta.
// O SDK nativo lembra o usuário entre sessões, por isso só logamos se ainda não houver um.
if (Auth.CurrentUser == null)
{
var result = await Auth.SignInAnonymouslyAsync();
Debug.Log($"Logged in anonymously: {result.User.UserId}");
}
IsReady = true;
}
}
- Crie um GameObject vazio
FirebaseBootstrapna sua primeira cena carregada (idealmente uma cena de "boot"/menu que carrega antes das pistas) e arraste o script.
5.5 (Fase futura — Android/iOS/Desktop) Script: LeaderboardService.cs
Em aberto, mesma ressalva da 5.3 — não usar agora. A versão que você vai usar de fato é a 5.5-WEB, logo abaixo (
LeaderboardServiceWebGL) — mesmos métodosSubmitTime/GetTopTimes, só o nome da classe muda, pra deixar claro qual arquivo pertence a qual caminho de integração caso os dois convivam no projeto (oRaceManager, seção 5.6, chama a versão WebGL por enquanto).
Crie em Assets/_Project/Scripts/Firebase/LeaderboardService.cs:
using System.Collections.Generic;
using System.Threading.Tasks; // Task: o "tipo de retorno" de métodos async
using UnityEngine;
using Firebase.Firestore;
using Firebase.Auth;
// POR QUE: representar UMA linha do ranking (nome + tempo) de forma organizada, em vez
// de passar dicionários soltos entre o serviço e a UI.
// ESTRATÉGIA: classe C# pura (NÃO é MonoBehaviour: não vai em GameObject nenhum),
// só um "pacote de dados".
// ATENÇÃO: o LeaderboardServiceWebGL.cs (5.5-WEB) declara esta MESMA classe. Se os dois
// arquivos convivem no projeto, apague esta declaração daqui (senão o C# reclama de classe
// duplicada); os dois serviços passam a usar a mesma LeaderboardEntry.
public class LeaderboardEntry
{
public string PlayerName;
public float TimeSeconds;
}
// POR QUE: concentrar num lugar só toda a conversa com o Firestore sobre o leaderboard
// (gravar tempo e ler o top N). O RaceManager e o menu só chamam SubmitTime/GetTopTimes,
// sem saber nada de coleções, documentos ou do formato do banco.
// ESTRATÉGIA: classe "static" (não precisa criar objeto nem colocar em cena; chama
// LeaderboardService.SubmitTime(...) direto). Não guarda estado próprio: usa o Db/Auth
// do FirebaseBootstrap. Os métodos são async porque falam com a internet (ver FirebaseBootstrap).
// Caminho no banco: leaderboards/rallysurvive/{pista}/{uid} (padrão do GDD Core).
public static class LeaderboardService
{
// Grava o tempo do jogador na pista, mas SÓ se for melhor que o recorde pessoal salvo.
// Um documento por jogador por pista (ID = UID), então cada um aparece no ranking uma vez só.
public static async Task SubmitTime(string track, float timeSeconds, string playerName)
{
if (!FirebaseBootstrap.IsReady) return; // Firebase ainda não pronto (ou falhou): não faz nada
string uid = FirebaseBootstrap.Auth.CurrentUser.UserId;
// Monta o "endereço" do documento: coleção → documento → subcoleção → documento.
DocumentReference docRef = FirebaseBootstrap.Db
.Collection("leaderboards")
.Document("rallysurvive")
.Collection(track)
.Document(uid);
// Lê o tempo atual do jogador antes de sobrescrever: só salva se o novo for melhor
var snapshot = await docRef.GetSnapshotAsync();
if (snapshot.Exists)
{
float existingTime = snapshot.GetValue<float>("timeSeconds");
if (timeSeconds >= existingTime) // em contrarrelógio, MENOR é melhor
{
Debug.Log("Time is not better than the saved personal record — not updating.");
return;
}
}
// O Firestore recebe os campos como um dicionário nome → valor.
var data = new Dictionary<string, object>
{
{ "playerName", playerName },
{ "timeSeconds", timeSeconds },
{ "timestamp", Firebase.Firestore.Timestamp.GetCurrentTimestamp() }
};
await docRef.SetAsync(data); // SetAsync cria ou substitui o documento inteiro
Debug.Log("Time successfully submitted to the leaderboard.");
}
// Lê os "limit" melhores tempos da pista, do mais rápido para o mais lento.
// Task<List<...>> = método async que, quando termina, devolve uma lista.
public static async Task<List<LeaderboardEntry>> GetTopTimes(string track, int limit = 10)
{
var result = new List<LeaderboardEntry>();
if (!FirebaseBootstrap.IsReady) return result; // devolve lista vazia em vez de dar erro
// Consulta: todos os documentos da pista, ordenados por tempo crescente, só os N primeiros.
Query query = FirebaseBootstrap.Db
.Collection("leaderboards")
.Document("rallysurvive")
.Collection(track)
.OrderBy("timeSeconds")
.Limit(limit);
QuerySnapshot snapshot = await query.GetSnapshotAsync();
// Converte cada documento do banco num LeaderboardEntry
foreach (DocumentSnapshot doc in snapshot.Documents)
{
result.Add(new LeaderboardEntry
{
PlayerName = doc.GetValue<string>("playerName"),
TimeSeconds = doc.GetValue<float>("timeSeconds")
});
}
return result;
}
}
5.3-WEB Integração via REST API (WebGL/itch.io — sem SDK nativo)
Definido: para WebGL, em vez do SDK nativo, falamos direto com dois serviços do Firebase por HTTPS, usando UnityWebRequest (que roda normalmente dentro do navegador em builds WebGL):
- Identity Toolkit REST API (
identitytoolkit.googleapis.com) — faz o login anônimo e devolve umidToken(token de autenticação) e umlocalId(equivalente aouid). - Firestore REST API (
firestore.googleapis.com) — lê/escreve documentos, autenticado com oidTokenacima no headerAuthorization: Bearer.
Nenhum pacote adicional precisa ser importado no Unity — é tudo UnityWebRequest + JSON, já disponíveis. Os dois endpoints suportam CORS, então as chamadas funcionam direto do navegador sem precisar de um backend/proxy intermediário.
Você vai precisar só do apiKey e do projectId que pegou na seção 5.2.
5.4-WEB Script: FirebaseBootstrapWebGL.cs (login anônimo via REST)
Crie em Assets/_Project/Scripts/Firebase/FirebaseBootstrapWebGL.cs:
using System;
using UnityEngine;
using UnityEngine.Networking; // UnityWebRequest: requisições HTTP que funcionam também no WebGL
// POR QUE: mesmo papel do FirebaseBootstrap (seção 5.4): logar o jogador anonimamente uma
// vez por sessão. Mas o SDK nativo não funciona em WebGL, então fazemos o login "na mão",
// por HTTP, na API REST do Firebase (Identity Toolkit).
// ESTRATÉGIA: MonoBehaviour na primeira cena, que sobrevive às trocas de cena
// (DontDestroyOnLoad, ver FirebaseBootstrap). Faz um POST pro endpoint de cadastro anônimo e
// guarda o idToken (o "crachá" que prova quem somos em cada chamada ao Firestore) e o Uid em
// propriedades static, que o LeaderboardServiceWebGL lê. IsReady avisa quando está tudo pronto.
public class FirebaseBootstrapWebGL : MonoBehaviour
{
// Preencha com os valores de Project Settings → General → Your apps → Web app.
// const = valor fixo, definido na hora de compilar.
private const string ApiKey = "SUA_API_KEY_AQUI";
public const string ProjectId = "SEU_PROJECT_ID_AQUI"; // público porque o LeaderboardServiceWebGL monta as URLs com ele
public static string IdToken { get; private set; } // token de login, vai no header "Authorization: Bearer ..."
public static string Uid { get; private set; } // ID único do jogador (vira o ID do documento no leaderboard)
public static bool IsReady { get; private set; }
// Guarda qual objeto é "o" bootstrap da sessão. Necessário porque esta cena (menu) é
// recarregada toda vez que o jogador volta de uma corrida: sem isso, cada volta ao menu
// criaria um NOVO login anônimo (novo Uid) e o recorde pessoal ficaria "perdido".
private static FirebaseBootstrapWebGL instance;
// POR QUE esta classe interna: JsonUtility (o conversor de JSON do Unity) só sabe preencher
// classes com campos de mesmo nome que as chaves do JSON. Ela é o "molde" da resposta.
// [Serializable] é obrigatório pro JsonUtility enxergar a classe.
[Serializable]
private class SignUpResponse
{
public string idToken;
public string localId; // é o UID do usuário
public string refreshToken; // serviria pra renovar o idToken depois de 1h (não usado ainda)
}
// Awake assíncrono (ver FirebaseBootstrap): faz o login anônimo por HTTP sem travar o jogo.
async void Awake()
{
// Já existe um bootstrap vivo desta sessão (voltamos ao menu): descarta este duplicado.
if (instance != null)
{
Destroy(gameObject);
return;
}
instance = this;
DontDestroyOnLoad(gameObject);
string url = $"https://identitytoolkit.googleapis.com/v1/accounts:signUp?key={ApiKey}";
string body = "{\"returnSecureToken\":true}"; // JSON mínimo pedindo um token de volta
// "using var": o request é liberado (Dispose) automaticamente no fim do método —
// UnityWebRequest segura memória nativa e precisa ser liberado.
using var request = new UnityWebRequest(url, "POST");
byte[] bodyBytes = System.Text.Encoding.UTF8.GetBytes(body);
request.uploadHandler = new UploadHandlerRaw(bodyBytes); // o que enviamos
request.downloadHandler = new DownloadHandlerBuffer(); // onde a resposta é guardada
request.SetRequestHeader("Content-Type", "application/json");
// A partir do Unity 2023.1 / Unity 6, dá pra dar "await" direto numa requisição.
// (Em versões mais antigas seria preciso usar uma coroutine com "yield return".)
await request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"Firebase anonymous sign-in failed: {request.error}\n{request.downloadHandler.text}");
return; // IsReady continua false → o leaderboard fica desligado nesta sessão
}
// Converte o texto JSON da resposta num objeto SignUpResponse.
var response = JsonUtility.FromJson<SignUpResponse>(request.downloadHandler.text);
IdToken = response.idToken;
Uid = response.localId;
IsReady = true;
Debug.Log($"Logged in anonymously (REST): {Uid}");
}
}
- Crie um GameObject vazio
FirebaseBootstrapWebGLna sua primeira cena carregada e arraste o script — mesma posição que oFirebaseBootstrapteria na versão SDK nativo. - Substitua
SUA_API_KEY_AQUI/SEU_PROJECT_ID_AQUIpelos valores reais do console (aapiKeydo Firebase não é secreta — ela só identifica o projeto; a segurança de verdade vem das regras do Firestore na seção 5.7, que continuam valendo do mesmo jeito para chamadas REST). - Em aberto: o
idTokenexpira em 1h (expiresInna resposta). Para uma corrida de alguns minutos isso não é problema; se sessões mais longas passarem a importar, dá pra trocar orefreshTokenpor um novoidTokenviasecuretoken.googleapis.com/v1/tokenquando isso for necessário.
5.5-WEB Script: LeaderboardServiceWebGL.cs
Crie em Assets/_Project/Scripts/Firebase/LeaderboardServiceWebGL.cs:
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
// Uma linha do ranking (nome + tempo): classe C# pura, só dados (ver LeaderboardService, seção 5.5).
// Se o LeaderboardService.cs nativo também existir no projeto, só UMA das duas declarações pode ficar.
public class LeaderboardEntry
{
public string PlayerName;
public float TimeSeconds;
}
// POR QUE: mesmo papel do LeaderboardService (seção 5.5): gravar o tempo e ler o top N.
// Mas, como o SDK nativo não roda em WebGL, aqui a conversa com o Firestore é feita "na mão",
// por HTTP (API REST), com UnityWebRequest.
// ESTRATÉGIA: classe static com os mesmos métodos públicos (SubmitTime/GetTopTimes) do
// serviço nativo, pra quem chama (RaceManager, menu) não precisar mudar se um dia trocarmos
// de caminho. A parte chata é o JSON: o Firestore REST embrulha cada valor num objeto de tipo
// ({"stringValue": "..."}, {"doubleValue": 12.3}), então criamos pequenas classes-molde
// [Serializable] pro JsonUtility conseguir ler as respostas. Pra ENVIAR, montamos o JSON como texto.
public static class LeaderboardServiceWebGL
{
private const string BaseUrl = "https://firestore.googleapis.com/v1/projects";
// Moldes do formato de valor do Firestore REST: cada campo vem como { "<tipo>Value": valor }.
// [Serializable] é necessário pro JsonUtility (ver FirebaseBootstrapWebGL).
[Serializable] private class StringValue { public string stringValue; }
[Serializable] private class DoubleValue { public double doubleValue; }
[Serializable] private class TimestampValue { public string timestampValue; }
// Os campos do nosso documento de leaderboard (os nomes precisam bater com os do banco).
[Serializable]
private class LeaderboardFields
{
public StringValue playerName;
public DoubleValue timeSeconds;
public TimestampValue timestamp;
}
// Um documento do Firestore: "name" é o caminho completo dele, "fields" são os dados.
[Serializable]
private class FirestoreDocument
{
public string name;
public LeaderboardFields fields;
}
// Cada item da resposta do runQuery (consulta) traz um "document".
[Serializable] private class RunQueryEntry { public FirestoreDocument document; }
// Grava o tempo do jogador, SÓ se for melhor que o recorde pessoal (mesma regra da versão nativa).
// Em dois passos: GET (lê o documento atual) e, se valer a pena, PATCH (cria/sobrescreve).
public static async Task SubmitTime(string track, float timeSeconds, string playerName)
{
if (!FirebaseBootstrapWebGL.IsReady) return; // sem login, não há como gravar
string uid = FirebaseBootstrapWebGL.Uid;
// Endereço REST do documento: .../documents/leaderboards/rallysurvive/{pista}/{uid}
string docUrl = $"{BaseUrl}/{FirebaseBootstrapWebGL.ProjectId}/databases/(default)/documents/leaderboards/rallysurvive/{track}/{uid}";
// Lê o tempo atual do jogador antes de sobrescrever — só salva se for melhor
using (var getRequest = UnityWebRequest.Get(docUrl))
{
// O idToken (do FirebaseBootstrapWebGL) prova ao Firestore quem está pedindo;
// as regras de segurança (seção 5.7) usam isso.
getRequest.SetRequestHeader("Authorization", $"Bearer {FirebaseBootstrapWebGL.IdToken}");
await getRequest.SendWebRequest(); // await numa requisição (ver FirebaseBootstrapWebGL)
if (getRequest.result == UnityWebRequest.Result.Success)
{
var existing = JsonUtility.FromJson<FirestoreDocument>(getRequest.downloadHandler.text);
// "?." = só continua se o da esquerda não for null (evita erro se faltar algo).
if (existing?.fields?.timeSeconds != null && timeSeconds >= existing.fields.timeSeconds.doubleValue)
{
Debug.Log("Time is not better than the saved personal record — not updating.");
return;
}
}
// 404 = ainda não existe registro pra esse jogador — segue normalmente pro PATCH.
}
// Monta o JSON do documento à mão, no formato de valores tipados do Firestore.
// InvariantCulture garante ponto decimal (12.34): em pt-BR o ToString() daria "12,34",
// que é JSON inválido.
string isoTimestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", System.Globalization.CultureInfo.InvariantCulture);
string body = "{\"fields\":{"
+ $"\"playerName\":{{\"stringValue\":\"{Escape(playerName)}\"}},"
+ $"\"timeSeconds\":{{\"doubleValue\":{timeSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)}}},"
+ $"\"timestamp\":{{\"timestampValue\":\"{isoTimestamp}\"}}"
+ "}}";
// PATCH num documento: cria se não existir, substitui os campos se existir.
using var patchRequest = new UnityWebRequest(docUrl, "PATCH");
patchRequest.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body));
patchRequest.downloadHandler = new DownloadHandlerBuffer();
patchRequest.SetRequestHeader("Content-Type", "application/json");
patchRequest.SetRequestHeader("Authorization", $"Bearer {FirebaseBootstrapWebGL.IdToken}");
await patchRequest.SendWebRequest();
if (patchRequest.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"Failed to submit time: {patchRequest.error}\n{patchRequest.downloadHandler.text}");
return;
}
Debug.Log("Time successfully submitted to the leaderboard.");
}
// Lê os "limit" melhores tempos da pista (menor tempo primeiro), usando o endpoint
// runQuery do Firestore REST (o equivalente HTTP do OrderBy + Limit da versão nativa).
public static async Task<List<LeaderboardEntry>> GetTopTimes(string track, int limit = 10)
{
var result = new List<LeaderboardEntry>();
if (!FirebaseBootstrapWebGL.IsReady) return result; // lista vazia em vez de erro
// A consulta roda "dentro" do documento leaderboards/rallysurvive, na subcoleção {track}.
string parentUrl = $"{BaseUrl}/{FirebaseBootstrapWebGL.ProjectId}/databases/(default)/documents/leaderboards/rallysurvive:runQuery";
string body = "{\"structuredQuery\":{"
+ $"\"from\":[{{\"collectionId\":\"{track}\"}}],"
+ "\"orderBy\":[{\"field\":{\"fieldPath\":\"timeSeconds\"},\"direction\":\"ASCENDING\"}],"
+ $"\"limit\":{limit}"
+ "}}";
using var request = new UnityWebRequest(parentUrl, "POST");
request.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body));
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json");
request.SetRequestHeader("Authorization", $"Bearer {FirebaseBootstrapWebGL.IdToken}");
await request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"Failed to fetch leaderboard: {request.error}\n{request.downloadHandler.text}");
return result;
}
// runQuery devolve um array JSON puro — envolve num objeto pra JsonUtility conseguir ler.
// (JsonUtility não aceita um array solto na raiz, só objetos.)
string wrapped = "{\"items\":" + request.downloadHandler.text + "}";
var parsed = JsonUtility.FromJson<Wrapper>(wrapped);
foreach (var entry in parsed.items)
{
// Pista sem nenhum tempo: o runQuery devolve um item só com "readTime", sem documento.
// Cuidado: o JsonUtility nunca deixa campos de classe null (ele cria um objeto vazio),
// então "document == null" não basta; um documento real sempre tem "name" preenchido.
if (entry.document == null || string.IsNullOrEmpty(entry.document.name)) continue;
result.Add(new LeaderboardEntry
{
PlayerName = entry.document.fields.playerName.stringValue,
TimeSeconds = (float)entry.document.fields.timeSeconds.doubleValue
});
}
return result;
}
// Molde que "embrulha" o array da resposta do runQuery (ver comentário acima).
[Serializable] private class Wrapper { public RunQueryEntry[] items; }
// Escapa barra invertida e aspas do nome, senão um nome com " quebraria o JSON montado à mão.
private static string Escape(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\"");
}
Nota: para simplificar, essa versão assume que os nomes dos jogadores não trazem caracteres exóticos além de aspas/barra (tratados em
Escape). Se mais tarde adicionar um campo de nome livre, vale trocar essa concatenação manual por um serializador JSON de verdade (ex.:Newtonsoft.Jsonvia UPM) — não bloqueante para o MVP.
5.6 Linha de chegada e envio de tempo ao final da corrida
Quando o carro cruza a linha de chegada, a corrida termina e o tempo é enviado ao Firestore (a checagem de "só salva se for melhor que o recorde pessoal" já está embutida em LeaderboardServiceWebGL.SubmitTime, seção 5.5-WEB — nenhuma lógica extra é necessária aqui).
1. Criar a zona da linha de chegada:
- Na cena de corrida, crie um GameObject vazio
FinishLine, posicionado no ponto final da pista. - Adicione um
Polygon Collider 2D(ouBox Collider 2D, se a linha de chegada for reta) e marqueIs Trigger. - Redimensione/rotacione o collider pra cobrir a largura da pista nesse ponto, como uma "porta" que o carro atravessa — mesma lógica de ajuste usada na
TrackValidZone(seção 3.3).
2. Script: FinishLine.cs
Crie em Assets/_Project/Scripts/Track/FinishLine.cs e coloque no GameObject FinishLine:
using UnityEngine;
// POR QUE: detectar o momento em que o carro cruza a chegada e encerrar a corrida.
// ESTRATÉGIA: mesmo padrão do TrackBoundary/TrackWaypoint: um trigger que só AVISA o
// RaceManager. Quem para o cronômetro e abre o menu de chegada é o RaceManager/UI.
public class FinishLine : MonoBehaviour
{
// Ao contrário do TrackBoundary (que usa OnTriggerExit2D pra saber
// quando o carro deixa a pista), aqui o que importa é o instante em
// que o carro ENTRA na linha de chegada.
void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Car"))
{
RaceManager.Instance.OnRaceFinished();
}
}
}
3. Adicionar OnRaceFinished() e SubmitFinishedTime() ao RaceManager.cs (da Fase 3):
// Acréscimos ao RaceManager (Parte 10 §3.6). O que muda: agora a corrida pode TERMINAR
// (além de bater), e o RaceManager ganha o estado Finished + o envio do tempo.
// Mesmo papel do Crashed, mas pra chegada: a UI (FinishMenuUI) observa este valor.
public bool Finished { get; private set; } // true enquanto o menu de "digite seu nome" está na tela
// Chamado pela FinishLine quando o carro cruza a chegada.
// Para o cronômetro, marca como terminada e força o progresso a 100%.
public void OnRaceFinished()
{
if (!RaceActive) return; // evita reabrir o menu se o collider disparar mais de uma vez
RaceActive = false;
Finished = true;
TrackProgress.Instance.ForceComplete(); // garante 100% mesmo que o último waypoint tenha sido pulado — ver Parte 10 §3.7
Debug.Log($"Race finished! Time: {ElapsedTime}");
// O envio ao leaderboard só acontece depois que o jogador confirma um nome
// no FinishMenuUI (item 4 abaixo) — ver SubmitFinishedTime abaixo.
}
// Envia o tempo final ao leaderboard com o nome digitado. Chamado pelo botão Confirmar.
// Separado do OnRaceFinished porque o nome só existe DEPOIS que o jogador digita.
// "async void" porque é disparado por um botão e ninguém espera o resultado (ver FirebaseBootstrap).
public async void SubmitFinishedTime(string playerName)
{
await LeaderboardServiceWebGL.SubmitTime("desert", ElapsedTime, playerName);
}
O guard
if (!RaceActive) return;é necessário porque, diferente doOnCarLeftTrack()(que agora só pausa e espera input — Fase 3/4), aqui a cena continua rodando enquanto o jogador digita o nome — sem o guard, se o carro ficar oscilando sobre o collider da linha de chegada,OnTriggerEnter2Dpoderia disparar de novo e reabrir o menu.
Se um dia migrar para a versão SDK nativo (seção 5.5, Android/iOS/Desktop), a chamada dentro de
SubmitFinishedTimetroca só de classe:LeaderboardService.SubmitTime(...)no lugar deLeaderboardServiceWebGL.SubmitTime(...)— a assinatura do método é idêntica.
Em aberto: o parâmetro
"desert"está fixo — quando as pistas Floresta e Gelo existirem (seção 7.3), esse valor precisa vir de um campo configurável por cena (ex.: uma variável[SerializeField] private string trackIdnoRaceManager, preenchida no Inspector de cada cena) em vez de ficar hardcoded.
4. Menu de chegada: tempo final, nome do jogador (até 5 caracteres), repetir e voltar ao menu
Ver decisão de design na Parte 2 §"Menus de Feedback (Colisão e Chegada)". Quando
RaceManager.Finishedviratrue, mostra um painel com o tempo final, um campo pra digitar o nome antes de chamarSubmitFinishedTime, e dois botões — Repetir Pista e Voltar ao Menu — sempre disponíveis, mesmo antes de o jogador confirmar um nome.
Estrutura da UI (no mesmo Canvas da Fase 4):
- Crie um painel
FinishPanel(UI → Panel), desativado por padrão. - Dentro dele:
- Um texto TextMeshPro
FinalTimeText, mesmo formato doTimerText(Fase 4, seção 4.1) — mostra o tempo final parado, já que oTimerTextcontinua exibindo o mesmo valor por trás do painel. - Um
TMP_InputFieldchamadoNameInputField(campoCharacter Limit=5no Inspector) e umButtonchamadoConfirmButtoncom um texto tipo "Confirmar". - Um
ButtonchamadoRetryButtoncom o texto "Repetir Pista". - Um
ButtonchamadoMainMenuButtoncom o texto "Voltar ao Menu".
- Um texto TextMeshPro
Script FinishMenuUI.cs, em Assets/_Project/Scripts/UI/FinishMenuUI.cs:
using UnityEngine;
using UnityEngine.UI; // Button
using TMPro; // TMP_Text, TMP_InputField
// POR QUE: ao cruzar a chegada, o jogador precisa ver o tempo final, digitar um nome
// (até 5 letras) pra enviar ao ranking, e poder repetir a pista ou voltar ao menu.
// ESTRATÉGIA: mesmo padrão do CrashMenuUI: a UI OBSERVA o RaceManager (Finished) no Update
// e mostra/esconde o painel. A diferença é que aqui usamos BOTÕES de UI: no Start ligamos
// cada botão a um método (AddListener), e o Unity chama esse método quando o botão é clicado.
// Como o CrashMenuUI, este script tem que ficar num objeto ATIVO (ex.: o Canvas), não no
// FinishPanel desativado.
public class FinishMenuUI : MonoBehaviour
{
private const int MaxNameLength = 5;
[SerializeField] private GameObject finishPanel;
[SerializeField] private TMP_Text finalTimeText;
[SerializeField] private TMP_InputField nameInputField; // campo onde o jogador digita o nome
[SerializeField] private Button confirmButton;
[SerializeField] private Button retryButton;
[SerializeField] private Button mainMenuButton;
// Start (ver TrackProgress): liga os botões aos métodos e esconde o painel.
// onClick.AddListener(método) = "quando clicar, chame este método".
// "() => ..." é uma lambda: uma mini-função sem nome, escrita ali mesmo.
void Start()
{
nameInputField.characterLimit = MaxNameLength; // reforça no código o mesmo limite configurado no Inspector
confirmButton.onClick.AddListener(OnConfirm);
retryButton.onClick.AddListener(() => RaceManager.Instance.RetryRace()); // reaproveita o método da Fase 3 (Parte 10 §3.6)
mainMenuButton.onClick.AddListener(() => RaceManager.Instance.QuitToMainMenu()); // idem
finishPanel.SetActive(false);
}
// Update (ver CarController): abre o painel no frame em que a corrida termina, e nesse
// mesmo momento escreve o tempo final (uma vez só, não todo frame).
void Update()
{
bool finished = RaceManager.Instance.Finished;
if (finishPanel.activeSelf != finished)
{
finishPanel.SetActive(finished);
if (finished)
finalTimeText.text = FormatTime(RaceManager.Instance.ElapsedTime);
}
}
// Chamado pelo botão Confirmar: valida o nome e pede ao RaceManager pra enviar o tempo.
private void OnConfirm()
{
string playerName = nameInputField.text.Trim(); // Trim tira espaços do começo/fim
if (string.IsNullOrEmpty(playerName))
playerName = "PLAYR"; // fallback de 5 caracteres se o jogador confirmar sem digitar nada — ver "Em aberto" na Parte 2
confirmButton.interactable = false; // evita reenvio duplicado com cliques repetidos
RaceManager.Instance.SubmitFinishedTime(playerName);
}
// Mesmo formato usado em TimerDisplay.cs (Fase 4) e LeaderboardRowUI.cs (Fase 6).
// "static": não usa nada do objeto, só a entrada; é uma função utilitária.
private static string FormatTime(float time)
{
int minutes = Mathf.FloorToInt(time / 60f);
int seconds = Mathf.FloorToInt(time % 60f);
int centiseconds = Mathf.FloorToInt((time * 100f) % 100f);
return $"{minutes:00}:{seconds:00}.{centiseconds:00}";
}
}
RetryButtoneMainMenuButtonchamam diretamenteRaceManager.RetryRace()/QuitToMainMenu()(já existentes desde a Fase 3, seção 3.6, usados peloCrashMenuUI) — nenhum método novo noRaceManageré necessário. Como ficam interagíveis o tempo todo (diferente doConfirmButton, que se desativa após o envio), o jogador pode repetir a pista ou voltar ao menu sem precisar salvar o tempo, se preferir.
Arraste o script num GameObject ativo da cena (ex.: no Canvas — não no próprio FinishPanel, pelo mesmo motivo do CrashMenuUI na Fase 4 §4.3) e ligue Finish Panel, Final Time Text, Name Input Field, Confirm Button, Retry Button e Main Menu Button aos respectivos objetos criados acima.
5.7 Regras de segurança do Firestore (importante!)
Sem regras, qualquer um pode escrever qualquer coisa no seu banco. No console, aba Firestore → Rules, configure algo assim como ponto de partida:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /leaderboards/{game}/{track}/{userId} {
allow read: if true;
allow write: if request.auth != null && request.auth.uid == userId;
}
}
}
Isso garante que: qualquer um pode ler o ranking, mas só o próprio jogador (autenticado, mesmo anonimamente) pode escrever no seu próprio documento de tempo. Essas regras valem igualmente para as chamadas REST da versão WebGL — o Firestore valida o idToken do header Authorization do mesmo jeito, não importa se ele chegou via SDK nativo ou via UnityWebRequest.
✅ Checkpoint da Fase 5 (WebGL)
- Rodar a cena no Unity Editor (o REST funciona em qualquer plataforma, então dá pra testar sem precisar buildar WebGL toda hora) → cruzar a linha de chegada → o
FinishPanelaparece com o tempo final e pedindo um nome (máx. 5 caracteres). - Digitar um nome e confirmar → aparece no Console "Time successfully submitted to the leaderboard." e
ConfirmButtonfica desativado (evita reenvio). - Confirmar sem digitar nada → o nome salvo cai no fallback
"PLAYR"(ver seção 5.6, item 4). - Sem confirmar nome nenhum, apertar
RetryButton→ a pista recarrega do zero (mesmo efeito doRnoCrashMenuUI, Fase 4 §4.3), sem enviar tempo ao leaderboard. - Sem confirmar nome nenhum, apertar
MainMenuButton→ tenta carregarMainMenu(só funciona de fato a partir da Fase 6), sem enviar tempo ao leaderboard. - Conferir no Firebase Console (Firestore Database) que o documento foi criado em
leaderboards/rallysurvive/desert/{localId}, com oplayerNamedigitado. - Chamar
LeaderboardServiceWebGL.GetTopTimes("desert")(ex.: num script de teste temporário) e confirmar que retorna a lista ordenada. - Fazer um build WebGL real (
File → Build and Run) e repetir os dois testes acima rodando no navegador — o Editor não pega alguns bugs específicos de CORS/sandbox de WebGL.
Problemas comuns
PERMISSION_DENIEDao escrever: confira seFirebaseBootstrapWebGL.IsReadyjá étrue(login anônimo concluído) antes de chamarSubmitTime— adicione um log pra confirmar a ordem de execução.- Erro 400 "Invalid JSON payload" no PATCH: normalmente é
timeSecondssendo formatado com vírgula em vez de ponto decimal (problema deCultureInfodo sistema) — confira se oToString(CultureInfo.InvariantCulture)está presente, como no script acima. - CORS bloqueado no navegador: não deveria acontecer com os domínios
identitytoolkit.googleapis.com/firestore.googleapis.com(eles suportam CORS nativamente); se aparecer, normalmente é sinal de URL/endpoint digitado errado, não um problema de CORS de verdade. - Funciona no Editor mas não no build WebGL: teste sempre via
Build and Run(que sobe um servidor local automaticamente) — abrir oindex.htmldireto do disco (file://) quebra por causa das mesmas restrições de CORS do navegador.
Próxima fase: Fase 6 (Menu Principal e Seleção de Pista) — usar GetTopTimes para montar a tela inicial com as pistas disponíveis e o ranking de cada uma.
Fase 6 — Menu Principal e Seleção de Pista
Objetivo: uma cena de menu, carregada antes das pistas, que mostra as 3 pistas do lançamento (Deserto, Floresta, Gelo — ver Parte 2 §"Pistas (3 no lançamento, 1 pós-MVP)"; Noturna fica de fora até entrar em produção) e o grid dos melhores tempos de cada uma, lidos do Firestore via
LeaderboardServiceWebGL.GetTopTimes(Fase 5).Definido (ver Parte 2 §"Menu Principal"): tocar no botão de uma pista já mostra o ranking daquela pista imediatamente e troca o background do menu pela imagem daquela pista (uma imagem de fundo por pista). Para começar a corrida, o jogador precisa tocar e segurar o mesmo botão por alguns segundos — não existe um botão "Jogar" separado. Isso evita que um toque rápido/acidental (ex.: rolando a lista) inicie uma corrida sem querer.
Pré-requisito: esta fase assume que as cenas das outras pistas já existem (
RaceForest,RaceIce, duplicadas deRaceDesertconforme a seção 7.3) e que você tem 3 imagens de background, uma por pista. Se alguma cena ainda não existir, o menu e o grid de tempos funcionam normalmente — só segurar o botão daquela pista vai falhar ao carregar; nesse caso, desabilite o botão até a cena existir.
6.1 Criar a cena de menu
- Em
Assets/_Project/Scenes/, crie uma nova cenaMainMenu. - Em
File → Build Settings, adicioneMainMenuà lista de cenas e arraste-a para o topo (índice 0) — é a cena que carrega primeiro quando o jogo abre. - Mova (ou recrie) o GameObject
FirebaseBootstrapWebGL(Fase 5, seção 5.4-WEB) para esta cena — como o script já usaDontDestroyOnLoad, o login anônimo acontece uma vez aqui e continua válido quando o jogador entra numa cena de corrida.
6.2 Estrutura de UI
- Hierarchy → UI → Canvas (mesma configuração de
Canvas Scaler → Scale With Screen Sizeusada na Fase 4). - Antes de tudo, dentro do Canvas, crie uma
ImagechamadaMenuBackground, esticada pra cobrir a tela inteira (Anchor= stretch/stretch) e posicionada atrás dos outros elementos na Hierarchy (primeiro filho do Canvas, já que a ordem de renderização segue a ordem de irmãos). É nela que as 3 imagens de pista vão ser trocadas. - Crie um painel
TrackListPanelcom 3 botões, um por pista do lançamento: Deserto, Floresta, Gelo. Cada botão precisa de:- Um texto TextMeshPro com o nome da pista.
- Uma
Imagefilha chamadaHoldProgress,Image Type = Filled(Fill Method = Radial 360ouHorizontal, à escolha), começando comFill Amount = 0— é o indicador visual de "quanto falta" pra segurar completar.
- Crie um painel
LeaderboardPanelao lado, com:- Um texto
SelectedTrackText(nome da pista selecionada). - Um
Scroll View(ou umaVertical Layout Groupsimples, já que são poucas linhas) chamadoLeaderboardContent, onde as linhas de ranking serão instanciadas. - Um texto
LoadingIndicator("Carregando...") e um textoEmptyLeaderboardText("Nenhum tempo registrado ainda"), ambos desativados por padrão.
- Um texto
- Crie um prefab
LeaderboardRowemAssets/_Project/Prefabs/UI/: umHorizontal Layout Groupcom 3 textos TextMeshPro (RankText,NameText,TimeText). - Importe as 3 imagens de background (uma por pista) em
Assets/_Project/Sprites/Menu/, comTexture Type = Sprite (2D and UI).
6.3 Script: LeaderboardRowUI.cs
Crie em Assets/_Project/Scripts/UI/LeaderboardRowUI.cs:
using UnityEngine;
using TMPro;
// POR QUE: cada linha do ranking no menu (posição, nome, tempo) é uma cópia de um prefab;
// alguém precisa preencher os 3 textos de cada cópia.
// ESTRATÉGIA: MonoBehaviour preso ao prefab LeaderboardRow. Os textos já vêm ligados no
// Inspector do prefab, então quem cria a linha (MainMenuController) só chama Setup(...)
// com os dados, sem precisar saber como a linha é montada por dentro.
public class LeaderboardRowUI : MonoBehaviour
{
[SerializeField] private TMP_Text rankText;
[SerializeField] private TMP_Text nameText;
[SerializeField] private TMP_Text timeText;
// Preenche a linha. Chamado logo depois do Instantiate pelo MainMenuController.
public void Setup(int rank, string playerName, float timeSeconds)
{
rankText.text = $"{rank}º";
nameText.text = playerName;
timeText.text = FormatTime(timeSeconds);
}
// Mesmo formato usado em TimerDisplay.cs (Fase 4) — mantém o tempo consistente entre HUD e menu.
private static string FormatTime(float time)
{
int minutes = Mathf.FloorToInt(time / 60f);
int seconds = Mathf.FloorToInt(time % 60f);
int centiseconds = Mathf.FloorToInt((time * 100f) % 100f);
return $"{minutes:00}:{seconds:00}.{centiseconds:00}";
}
}
Arraste o script no prefab LeaderboardRow e ligue os 3 campos aos respectivos TMP_Text no Inspector.
6.4 Script: TrackButtonHold.cs
Em aberto: a duração exata do "segurar" (placeholder abaixo: 1,5s) não está fechada — ajustar depois de testar em dispositivo real; segurar demais frustra, segurar de menos deixa fácil demais iniciar sem querer.
Crie em Assets/_Project/Scripts/UI/TrackButtonHold.cs:
using System; // Action (tipo usado nos eventos)
using UnityEngine;
using UnityEngine.EventSystems; // interfaces de clique/toque da UI (IPointerDownHandler etc.)
using UnityEngine.UI;
// POR QUE: o botão de pista tem DOIS comportamentos: toque rápido = só selecionar (mostrar
// ranking e fundo); tocar e SEGURAR = começar a corrida. O Button padrão do Unity só sabe
// "clicou", então criamos este componente.
// ESTRATÉGIA: MonoBehaviour que implementa interfaces do EventSystem. Ao "assinar" uma
// interface (IPointerDownHandler...), o Unity passa a chamar o método correspondente quando o
// dedo/mouse encosta, solta ou sai do botão (exige um EventSystem na cena, criado junto com o
// Canvas, e uma Image com Raycast Target no mesmo objeto). O script não sabe O QUE fazer ao
// tocar/completar: só dispara EVENTOS C# (OnTap, OnHoldComplete), e quem se inscreveu neles
// (o MainMenuController) decide. Assim o mesmo botão serve pra qualquer pista.
public class TrackButtonHold : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IPointerExitHandler
{
[SerializeField] private Image holdProgressImage; // Image Type = Filled — preenche conforme o jogador segura
[SerializeField] private float holdDurationSeconds = 1.5f; // placeholder — Em aberto
// "event Action" = um aviso que outros scripts podem assinar com "+=". Quando este script
// chama OnTap?.Invoke(), todos os inscritos são chamados. O "?." evita erro se ninguém
// tiver se inscrito.
public event Action OnTap; // dispara assim que o dedo/mouse encosta no botão
public event Action OnHoldComplete; // dispara só se o toque durar holdDurationSeconds sem soltar
private bool _isHolding; // o dedo está pressionando o botão agora?
private bool _completed; // já completou o "segurar" neste toque? (evita disparar duas vezes)
private float _holdStartTime; // momento (Time.time) em que o toque começou
// Chamado pelo EventSystem quando o dedo/mouse encosta no botão: começa a contar o tempo
// e já avisa o toque (seleciona a pista na hora).
public void OnPointerDown(PointerEventData eventData)
{
_isHolding = true;
_completed = false;
_holdStartTime = Time.time; // Time.time = segundos desde que o jogo começou
OnTap?.Invoke();
}
// Soltar o dedo ou arrastá-lo pra fora do botão cancela o "segurar".
// "=>" aqui é um método de uma linha só (mesmo que { CancelHold(); }).
public void OnPointerUp(PointerEventData eventData) => CancelHold();
public void OnPointerExit(PointerEventData eventData) => CancelHold(); // dedo saiu da área do botão
// Update (ver CarController): enquanto segura, atualiza a barra de progresso; quando
// o tempo completa, dispara OnHoldComplete uma única vez.
private void Update()
{
if (!_isHolding || _completed) return;
float elapsed = Time.time - _holdStartTime;
if (holdProgressImage != null)
holdProgressImage.fillAmount = Mathf.Clamp01(elapsed / holdDurationSeconds);
if (elapsed >= holdDurationSeconds)
{
_completed = true;
_isHolding = false;
OnHoldComplete?.Invoke();
}
}
// Para de contar e zera a barra (a menos que já tenha completado; aí a cena já está trocando).
private void CancelHold()
{
_isHolding = false;
if (!_completed && holdProgressImage != null)
holdProgressImage.fillAmount = 0f;
}
}
Arraste o script em cada botão de pista (o mesmo GameObject que tem a Image de fundo do botão) e ligue o campo holdProgressImage à Image filha HoldProgress criada na seção 6.2.
6.5 Script: MainMenuController.cs
Crie em Assets/_Project/Scripts/UI/MainMenuController.cs:
using System.Collections.Generic;
using System.Threading.Tasks; // Task.Yield (esperar o login terminar)
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using TMPro;
// POR QUE: agrupar tudo que o menu precisa saber sobre UMA pista (id no banco, cena, nome,
// fundo, botão) num lugar só, em vez de 5 listas paralelas que podem sair de ordem.
// ESTRATÉGIA: classe C# pura marcada com [System.Serializable]. Isso faz o Inspector mostrar
// os campos dela dentro da lista "Tracks" do MainMenuController, e você preenche cada pista
// ali mesmo. Adicionar uma pista nova (ex.: Noturna) = adicionar um item na lista, sem código.
[System.Serializable]
public class TrackOption
{
public string TrackId; // ex.: "desert" — mesmo valor usado no SubmitTime/GetTopTimes (Fase 5)
public string SceneName; // ex.: "RaceDesert"
public string DisplayName; // ex.: "Deserto"
public Sprite BackgroundSprite; // imagem de fundo do menu para essa pista
public TrackButtonHold HoldButton; // o botão desta pista na tela (seção 6.4)
}
// POR QUE: o "cérebro" da tela de menu: liga cada botão de pista às ações de selecionar
// (troca fundo + carrega ranking) e jogar (carrega a cena da corrida).
// ESTRATÉGIA: MonoBehaviour único na cena MainMenu. No Start, se inscreve nos eventos de cada
// TrackButtonHold (padrão de eventos, ver TrackButtonHold). O ranking é buscado de forma
// async no LeaderboardServiceWebGL e as linhas são criadas a partir do prefab LeaderboardRow.
public class MainMenuController : MonoBehaviour
{
[SerializeField] private List<TrackOption> tracks = new(); // preenchida no Inspector, uma entrada por pista
[SerializeField] private Image menuBackground;
[SerializeField] private TMP_Text selectedTrackText;
[SerializeField] private Transform leaderboardContent; // "pai" onde as linhas do ranking são criadas
[SerializeField] private GameObject leaderboardRowPrefab;
[SerializeField] private GameObject loadingIndicator;
[SerializeField] private GameObject emptyLeaderboardText;
// A pista selecionada por último. Serve pra descartar respostas "velhas": se o jogador
// tocar Deserto e logo depois Gelo, a resposta do Deserto pode chegar depois e não deve
// aparecer misturada no ranking do Gelo.
private TrackOption currentTrack;
// Start (ver TrackProgress): inscreve o menu nos eventos de toque/segurar de cada botão
// e já pré-seleciona a primeira pista.
private void Start()
{
foreach (var track in tracks)
{
var capturedTrack = track; // evita o bug clássico de closure sobre a variável do loop
// "+=" inscreve uma lambda no evento: quando o botão avisar, ela é chamada com ESTA pista.
capturedTrack.HoldButton.OnTap += () => SelectTrack(capturedTrack);
capturedTrack.HoldButton.OnHoldComplete += () => PlayTrack(capturedTrack);
}
if (tracks.Count > 0)
SelectTrack(tracks[0]); // pré-seleciona a primeira pista só pro grid e o background já aparecerem preenchidos ao abrir o menu
}
// Mostra a pista escolhida: troca o fundo e o título, e busca o ranking dela no Firestore.
// "async void" porque é chamado por eventos e ninguém espera o resultado (ver FirebaseBootstrap).
private async void SelectTrack(TrackOption track)
{
currentTrack = track;
selectedTrackText.text = track.DisplayName;
menuBackground.sprite = track.BackgroundSprite;
ClearLeaderboardRows();
loadingIndicator.SetActive(true);
emptyLeaderboardText.SetActive(false);
// Na primeira abertura do jogo, o login anônimo (FirebaseBootstrapWebGL.Awake) ainda
// está em andamento quando este Start roda. Sem esta espera, GetTopTimes veria
// IsReady = false e o menu mostraria "Nenhum tempo registrado" mesmo com tempos no banco.
// Task.Yield() = "continue no próximo frame"; esperamos no máximo 10s.
float giveUpAt = Time.time + 10f;
while (!FirebaseBootstrapWebGL.IsReady && Time.time < giveUpAt)
await Task.Yield();
var topTimes = await LeaderboardServiceWebGL.GetTopTimes(track.TrackId);
// Depois de um await, o mundo pode ter mudado: o jogador pode ter selecionado outra
// pista (resposta velha, descarta) ou já ter entrado numa corrida (este objeto foi
// destruído junto com a cena; "this == null" detecta isso no Unity).
if (this == null || track != currentTrack) return;
loadingIndicator.SetActive(false);
if (topTimes.Count == 0)
{
emptyLeaderboardText.SetActive(true);
return;
}
// Cria uma linha (cópia do prefab) por tempo, como filha de leaderboardContent;
// o Layout Group do painel as empilha automaticamente.
for (int i = 0; i < topTimes.Count; i++)
{
var row = Instantiate(leaderboardRowPrefab, leaderboardContent);
row.GetComponent<LeaderboardRowUI>().Setup(i + 1, topTimes[i].PlayerName, topTimes[i].TimeSeconds);
}
}
// Apaga as linhas do ranking anterior antes de mostrar o novo.
// (Destroy só acontece no fim do frame, por isso é seguro chamá-lo dentro do foreach.)
private void ClearLeaderboardRows()
{
foreach (Transform child in leaderboardContent)
Destroy(child.gameObject);
}
// Segurou o botão até o fim: carrega a cena da corrida daquela pista.
// A cena precisa estar na lista de cenas do build (ver RaceManager.QuitToMainMenu).
private void PlayTrack(TrackOption track)
{
SceneManager.LoadScene(track.SceneName);
}
}
Se um dia migrar para o SDK nativo (seção 5.5, Android/iOS/Desktop), a única troca aqui é
LeaderboardServiceWebGL.GetTopTimes→LeaderboardService.GetTopTimes— mesma assinatura, igual já documentado na Fase 5.
6.6 Configurar no Inspector
-
Crie um GameObject vazio
MainMenuControllerna cenaMainMenue arraste o script. -
Preencha a lista
trackscom 3 entradas, uma por pista do lançamento:TrackId SceneName DisplayName BackgroundSprite desertRaceDesertDeserto imagem de fundo do Deserto forestRaceForestFloresta imagem de fundo da Floresta iceRaceIceGelo imagem de fundo do Gelo -
Arraste o botão correspondente de
TrackListPanel(já com oTrackButtonHoldda seção 6.4) no campoHoldButtonde cada entrada, e a respectiva imagem importada na seção 6.2 no campoBackgroundSprite. -
Ligue
menuBackground(aImageMenuBackgroundda seção 6.2),selectedTrackText,leaderboardContent(oTransformdoLeaderboardContent),leaderboardRowPrefab(o prefabLeaderboardRow),loadingIndicatoreemptyLeaderboardTextaos respectivos objetos criados na seção 6.2.
✅ Checkpoint da Fase 6
- Dar Play na cena
MainMenu→ a primeira pista (Deserto) já aparece selecionada, o background mostra a imagem do Deserto, e o grid mostra os tempos existentes no Firestore (ou o texto de "nenhum tempo ainda", se a coleção estiver vazia). - Tocar rapidamente em outra pista (sem segurar) atualiza
SelectedTrackText, troca o background pra imagem daquela pista, e recarrega o grid com os tempos dela, sem iniciar a corrida. - Tocar e segurar um botão de pista preenche visualmente o
HoldProgressaté completar e só então carrega a cena de corrida correspondente. - Soltar o dedo/mouse (ou arrastar pra fora do botão) antes de completar cancela o hold e zera o preenchimento, sem iniciar a corrida.
- Completar uma corrida (Fase 5) e voltar ao menu mostra o novo tempo refletido no grid (se foi o melhor tempo daquele jogador).
Problemas comuns
- Grid fica vários segundos em "Carregando..." e depois mostra "Nenhum tempo registrado": o
SelectTrackespera até 10s o login anônimo (FirebaseBootstrapWebGL.IsReady) terminar antes de buscar os tempos; se passou disso, o login falhou — procure o erroFirebase anonymous sign-in failedno Console (normalmenteApiKeyerrada ou login anônimo não habilitado no Firebase Console). - Segurar não carrega a cena: confira se
SceneNamena listatracksbate exatamente com o nome da cena emBuild Settings(case-sensitive), e se a cena foi de fato adicionada ao Build Settings. - Background não troca (ou fica em branco): confira se
BackgroundSpritefoi preenchido pra cada entrada detrackse semenuBackgroundestá ligado no Inspector — umImagesemspriteatribuído fica transparente/branco, não dá erro. HoldProgressnão preenche: confira se aImageestá comImage Type = Filled(umaImagenormal ignorafillAmount).- Hold completa mesmo arrastando o dedo pra fora do botão: em touchscreens isso pode acontecer se o Canvas não tiver um
Graphic Raycastercorretamente configurado — confirme que oEventSystemda cena está ativo e que nenhum outro elemento de UI está sobrepondo o botão e bloqueando oOnPointerExit. - Linhas de ranking duplicadas ao trocar de pista várias vezes: confira se
ClearLeaderboardRows()está sendo chamado antes de instanciar as novas linhas — sintoma comum de esquecer de destruir os filhos antigos deleaderboardContent.
Próxima fase: Fase 7 (Build e Próximos Passos) — gerar um build de teste e o que vem depois.
Fase 7 — Build de Teste e Próximos Passos
7.1 Gerar um build de teste (WebGL)
Definido: a plataforma de build é WebGL — é a única que não depende de módulos extras bloqueados pela falta de permissão de admin (ver nota de plataforma na Parte 12).
File → Build Settings→ selecione WebGL → Switch Platform (só na primeira vez; pode demorar alguns minutos).- Clique Add Open Scenes para incluir sua cena atual.
- Em Player Settings → Publishing Settings, configure o Compression Format: itch.io às vezes não envia o header
Content-Encodingcorreto para arquivos comprimidos, o que causa tela branca ao carregar. Duas soluções:- Marque Decompression Fallback (mantém compressão, o próprio JS do build descomprime no navegador) — mais seguro pra itch.io, recomendado; ou
- Mude Compression Format para Disabled (build maior no download, mas elimina o problema por completo).
- Clique Build e escolha uma pasta de saída (ex.:
Builds/WebGL/). - Teste local: use
File → Build and Run(o Unity sobe um servidor local automaticamente) — abrir oindex.htmldireto do disco (file://) não funciona, por restrição de CORS do navegador.
7.1-B Publicar no itch.io
- Compacte o conteúdo da pasta de build em
.zip(oindex.htmlprecisa estar na raiz do zip, não dentro de uma subpasta). - No itch.io, crie o projeto (ou edite um existente) → Kind of project: HTML.
- Faça upload do
.zipe marque a caixa "This file will be played in the browser". - Em Embed options, defina a resolução (viewport) igual à configurada em
Player Settings → Resolution and Presentationno Unity, pra evitar barras de rolagem/corte. - Publique como rascunho primeiro (visibilidade "Draft"/link restrito) e teste o link antes de tornar público.
7.2 Checklist antes de considerar o MVP "pronto"
- Carro se move corretamente em direção ao mouse, com aceleração exponencial suave.
- Partículas de movimento aparecem atrás do carro e trocam de preset corretamente (respingo só dentro de poça na Floresta, gelo/neve na cena Gelo) — Fase 1, seção 1.5.
- Chevrons aparecem e mudam de cor corretamente (verde/vermelho).
- Sair da pista reinicia a corrida e zera o tempo.
- Cronômetro visível e correto.
- Barra de velocidade (velocidade real, pós-grip) e % da pista percorrida atualizam corretamente durante a corrida (Fase 4, seções 4.4–4.5).
- Grip por pista funciona: Deserto sem modificador perceptível, Floresta escorrega só dentro das poças, Gelo escorrega o traçado inteiro (Fase 3, seções 3.8–3.10).
- Chuva ambiente aparece na
RaceForeste não em nenhuma outra pista (Fase 3, seção 3.11). - Ao cruzar a linha de chegada, a tela de chegada aparece com o tempo final e permite enviar o tempo, repetir a pista ou voltar ao menu (Fase 5, seção 5.6).
- Ao cruzar a linha de chegada, tempo é enviado ao Firestore via
LeaderboardServiceWebGL(se for melhor que o recorde pessoal). - Regras de segurança do Firestore configuradas (não deixado em modo "aberto" por engano).
- Testado no navegador via o link real do itch.io (não só no Unity Editor) — cobre bugs de CORS/sandbox que só aparecem fora do Editor.
- Menu principal mostra as 3 pistas do lançamento, troca o background ao selecionar cada uma, e carrega a cena correta ao segurar o botão.
- Grid de melhores tempos do menu bate com os documentos reais em
leaderboards/rallysurvive/{pista}no Firestore.
7.3 Floresta e Gelo já nascem junto com o Deserto
Diferente de versões anteriores deste guia, as pistas Floresta e Gelo não são mais um passo "depois" — elas nascem na própria Fase 3 (Parte 10 §3.12), a partir do Scene Template salvo da RaceDesert. Se você seguiu o guia em ordem, as 3 já existem e o Checklist §7.2 acima já cobre as três.
Noturna (pós-MVP): quando entrar em produção, segue o mesmo pipeline (Parte 10 §3.12) — nova cena a partir do template, mais uma entrada em MainMenuController.tracks (seção 6.6) com seu próprio BackgroundSprite.
7.4 Como isso conecta com os outros 2 jogos do GDD
- Navigation Expert: reaproveita 100% do
CarController,CarParticleseChevronGuidesem mudança. Você vai trocar oTrackBoundary/RaceManagerpor um sistema de checkpoints (novo scriptCheckpointManager.cs) e adicionar o minimapa + seta direcional — dá pra fazer uma sessão de implementação dedicada pra isso quando chegar a hora. - RaceLegenda: também reaproveita
CarController/ChevronGuide, incluindo o sistema de grip/CarTerrainSensor(Parte 10 §3.8–3.10) já pronto — só troca o que dispara ao sair da pista (aqui, perda de grip temporária em vez de reinício) e adiciona voltas múltiplas noRaceManager. Mais o sistema de setup do carro e o multiplayer via Firebase Realtime Database (esse último é bem mais complexo — merece uma sessão de implementação própria, depois de fecharmos os detalhes de sala/sincronização que você quer discutir).
7.5 O que fica pra uma próxima sessão de implementação
- Definido: arte vetorial flat minimalista (Parte 1 §4), gerada via SpriteCook (projeto "Rally2D") e organizada em
Sprites/Rally2D/neste repositório — osasset_idde cada arquivo estão emSprites/Rally2D/spritecook-assets.json. A leva anterior em pixel art (PixelLab,Sprites/RallySurvive/) fica substituída. O que já existe:- Carros em
cars/, todos vistos de cima apontando pra cima: o rally principal (rally_orange_v1/v2) mais 8 pinturas dele emcars/liveries/(mesmo formato, só a cor da carroceria muda — bom pra adversários/fantasmas e escolha de cor), e 6 modelos diferentes:hatchback_red,buggy_yellow,pickup_green,classic_coupe,street_tuner_purpleepolice(viatura pra ameaça externa do Street Legends). Em aberto: quais modelos entram em qual jogo e se têm atributos diferentes (Parte 1 §3, parâmetros por carro). - Sprite do
ChevronGuide(ui/chevron.png, branco pra tingir). - Barra de velocidade da HUD (
ui/speedbar_frame.png,ui/speedbar_fill.pnge a versão brancaui/speedbar_fill_white.png), feita paraImage Type = Fillede montada na Fase 4 §4.4. Não vem do SpriteCook: é desenhada pelo scriptSprites/Rally2D/_tools/speed_bar.py, porque o preenchimento precisa encaixar exatamente na moldura. Para mudar cores ou tamanho, edite o script e rode de novo. - Props por pista em
props/desert/(cactos, pedras, arbusto seco, barreiras, cones, pneus, faixa de checkpoint),props/forest/(árvores, arbusto, pedra com musgo, tronco e 2 poças —puddle_a/puddle_b, pra zona de poça da Parte 10 §3.9) eprops/ice/(pinheiros com neve, montes de neve, pedras de gelo, placa de gelo). - Decoração de cenário em
deco/common/(público em fileiras e grupos, torcedores avulsos, tenda, guarda-sol, arco de largada/chegada, postes com bandeirolas, fardos de feno, van de apoio — serve pras 3 pistas),deco/desert/(dunas, acácias, palmeira, capim seco, tumbleweed),deco/forest/(pinheiros, samambaia, flores, cogumelos, toco, público de guarda-chuva) edeco/ice/(bonecos de neve, público de roupa de inverno, cabana, lago congelado, arbustos com neve). Decalques de chão emdecals/(marcas de pneu retas/curvas/curtas, mancha de lama, pedrinhas) — usar com alpha reduzido noSpriteRendererpra não competir com a pista. - Tilesets de terreno em
tilesets/, um por pista (dirt_sand,grass_mud,snow_ice): autotile de 15 peças (grade 4×4, tile de 128px) com transição estrada ↔ terreno já desenhada.
- Carros em
- Import settings pra arte vetorial (diferente do pixel art): Filter Mode Bilinear, compressão ligada, sem Pixel Perfect Camera (o pacote saiu da Parte 8 §0.5). Pixels Per Unit 128 deixa 1 tile = 1 unidade de mundo.
- Em aberto: escala do carro e dos props em relação ao tile (os PNGs vêm em ~150–600px — ajustar pelo
Transformou por PPU próprio na hora de montar a cena). - Em aberto: a estrada continua como Sprite Shape (Parte 10 §3.1) usando o miolo de terra do tileset como Fill Texture, ou passa a ser pintada direto com o autotile de 15 peças (dual-grid) na Tilemap — o tileset suporta os dois caminhos.
- Trocar sprites placeholder pela arte final dentro da cena Unity (a arte em si já existe; falta o passo de importação/atribuição).
- Calibrar os valores exatos de grip (Gelo, poça da Floresta) e de
steeringResponsivenesscom playtesting real — hoje são placeholders (Parte 10 §3.8-3.9). - Sistema de áudio (motor, derrapagem — o som já tem um gancho natural no mesmo
CurrentGrip/InPuddleque já dirige as partículas, Fase 1 §1.5). - UI de ranking pós-corrida (ler
GetTopTimese mostrar numa tela de "Resultados" ao final de uma corrida, além do grid já disponível no menu — Fase 6).
Isso fecha o pipeline completo do RallySurvive, do zero até o leaderboard funcionando. Quando quiser, seguimos com uma sessão só pra Navigation Expert (checkpoints + minimapa) ou pra fechar os detalhes de sala do multiplayer do RaceLegenda.