Pular para o conteúdo principal

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​

  1. Baixe o Unity Hub em unity.com/download.
  2. 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).
  3. 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​

  1. No Hub, aba Projects → New Project.
  2. Escolha o template 2D (Core) (não "2D URP" por enquanto — menos complexidade pra quem tá começando; dá pra migrar depois se precisar).
  3. Nome do projeto: RallySurvive.
  4. 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 RaceDesert primeiro, com todo o sistema comum (carro, partículas, chevrons, pista, timer/UI), e a partir dela geramos RaceForest e RaceIce como 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 a RaceDesert em detalhe completo; a Parte 10 §3.8 em diante mostra a diferença pras outras duas.

  1. Em Assets/_Project/Scenes/, botão direito → Create → Scene. Nomeie RaceDesert.
  2. Dê duplo clique pra abrir.
  3. 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 RaceDesert existe 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​

  1. Na Hierarchy, botão direito → 2D Object → Sprite → Square (placeholder — depois trocamos pelo sprite real do carro).
  2. Renomeie para Car.
  3. Adicione componentes (botão Add Component no Inspector):
    • Rigidbody2D → mude Body Type para Dynamic, Gravity Scale para 0 (é topdown, sem gravidade), e marque Interpolate como Interpolate (deixa o movimento mais suave visualmente).
    • Box Collider 2D (ou Circle, se o sprite for redondo).

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 por rb.velocity (mesma coisa, nome antigo).

1.3 Configurar no Inspector​

  1. Arraste o script pro GameObject Car.
  2. Ajuste valores iniciais sugeridos:
    • Max Speed: 8
    • Min Distance For Movement: 0.3
    • Max Distance For Top Speed: 6
    • Steering 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):

  1. 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:
  2. 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.ActualSpeedNormalized e CarController.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:

  1. Selecione o GameObject Car na Hierarchy → Add Component → Particle System. Renomeie o componente/filho gerado para CarTrailParticles.
  2. Configure o básico pra um rastro atrás do carro: Shape = Cone ou Circle pequeno 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 Lifetime curto (~0.5-1s).
  3. 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.
  4. Só na cena RaceForest (criada na Parte 10 §3.12): duplique esse Particle System (Ctrl+D), renomeie para CarSplashParticles, troque a cor/textura pra respingo d'água (tons de azul/branco, partículas maiores e mais rápidas verticalmente).
  5. Só na cena RaceIce: troque a cor/textura do CarTrailParticles pra 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;
}
}
  1. Arraste o script no GameObject Car.
  2. No Inspector, ligue Car a ele mesmo, Trail Particles ao CarTrailParticles, e Splash Particles ao CarSplashParticles só na cena RaceForest (deixe vazio nas outras duas).

Dica opcional: pra dar mais leitura de velocidade, escale emission.rateOverTimeMultiplier proporcionalmente a car.ActualSpeedNormalized em 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 CurrentGrip ainda está sempre em 1, valor padrão — a diferença de derrapagem só aparece a partir da Fase 3, quando o CarTerrainSensor passa 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​

  1. Por enquanto, crie um sprite simples: 2D Object → Sprite → Triangle (serve de placeholder de seta — depois troca pelo sprite Sprites/Rally2D/ui/chevron.png, que é branco justamente pra ser tingido de verde/vermelho pela cor do SpriteRenderer).
  2. Renomeie para ChevronArrow.
  3. Arraste esse GameObject da Hierarchy pra pasta Assets/_Project/Prefabs/ — isso cria um Prefab.
  4. 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​

  1. Crie um GameObject vazio na Hierarchy: Create Empty → renomeie ChevronGuide.
  2. Arraste o script ChevronGuide.cs nele.
  3. No Inspector, arraste:
    • Car → o GameObject Car da cena.
    • Chevron Prefab → o prefab ChevronArrow criado no passo 2.1.
  4. Ajuste Max Chevrons (5) e Spacing (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 SpriteRenderer visível e que a Sorting Layer/Order in Layer está acima do chão da pista.
  • Chevrons ficam grudados no lugar errado: confira se car.transform.position está 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​

  1. 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).
  2. Renomeie o GameObject para Track_Desert.
  3. 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).
  4. 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).
  5. 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).
  6. 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.

  1. Hierarchy → botão direito → 2D Object → Tilemap → Rectangular. Renomeie para Tilemap_Terrain.
  2. Abra Window → 2D → Tile Palette, crie a paleta Palette_Desert em Assets/_Project/Tilemaps/, e arraste os sprites de terreno (dunas, grama, gelo) pra dentro.
  3. 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.
  4. Ajuste a Sorting Layer/Order in Layer: Tilemap_Terrain no fundo, Track_Desert (Sprite Shape) acima do terreno, decoração e chevrons acima da pista, carro no topo.
  5. 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.

  1. Crie um GameObject vazio: Create Empty → renomeie TrackValidZone.
  2. Adicione componente Polygon Collider 2D.
  3. Marque a opção Is Trigger ✅.
  4. 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 2D seguindo o contorno exato da spline — ativando isso, você pode usar esse collider gerado diretamente como TrackValidZone em 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 2D compostos, 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 Car cobrir a silhueta real do carro (ex.: um Box Collider 2D/Polygon Collider 2D ajustado 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​

  1. Selecione o GameObject Car.
  2. No topo do Inspector, campo Tag → Add Tag... → crie a tag Car.
  3. Volte no Car, selecione a tag Car recé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
}
}
  1. Crie um GameObject vazio RaceManager na 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

  1. Na Hierarchy, crie um GameObject vazio chamado TrackWaypoints. Ele é só uma pasta organizadora — todas as portas ficam dentro dele.
  2. 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. Nomeie Waypoint_00, Waypoint_01, Waypoint_02...
    • A ordem na Hierarchy importa: o script usa a posição do objeto dentro de TrackWaypoints como 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%.
  3. Em cada Waypoint_XX, adicione um Box Collider 2D com Is 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)
}
}
  1. Crie um GameObject vazio TrackProgress na Hierarchy (fora de TrackWaypoints) 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 TrackValidZone de cada pista o grip base que a caracteriza (ver Parte 2 §"Pistas"). Isso já é lido pelo CarController (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:

PistaGrip base do TrackValidZone
Deserto1.0 (padrão, sem modificador)
Floresta1.0 fora de poça — a poça em si tem seu próprio grip menor (seção 3.9)
Gelobaixo (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:

  1. Escolha manualmente alguns pontos ao longo do traçado (2-4 pra começar, ajustar depois de testar).
  2. Em cada um, crie um GameObject Puddle_00, Puddle_01, etc., dentro de uma pasta organizadora Puddles (igual ao padrão de TrackWaypoints, seção 3.7).
  3. Adicione um Circle Collider 2D (ou Box Collider 2D) com Is Trigger ✅, do tamanho de uma poça — bem menor que a largura total da pista, pra dar espaço pro jogador desviar.
  4. Arraste o script PuddleZone.cs.
  5. 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 das PuddleZone que o carro estiver sobrepondo (seção 3.9), e escrever o resultado em CarController.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.
}
}
  1. Arraste o script no GameObject Car (ele precisa do próprio Collider2D/Rigidbody2D do carro pra receber os eventos de trigger — já existem desde a Fase 1).
  2. No Inspector, ligue Car a ele mesmo e Track Boundary ao GameObject TrackValidZone da cena.

Nas cenas RaceDesert e RaceIce (sem poças), este script simplesmente nunca recebe OnTriggerEnter2D de uma PuddleZone — o carro fica sempre com o grip base da pista, lido uma vez em Start(). 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.

  1. Crie um Particle System como filho da Main Camera (Create Empty dentro dela → Add Component → Particle System), renomeie RainAmbient.
  2. Configure: Shape = Box, alinhada horizontalmente acima da área visível da câmera; Start Speed alta e Gravity Modifier positivo pra cair rápido de cima pra baixo; Start Lifetime curto (só o suficiente pra atravessar a tela); Rate over Time alto (chuva densa); sprite/textura fina (uma linha ou traço, não um círculo).
  3. Por estar dentro da Main Camera, a chuva sempre cobre a área visível, mesmo se a câmera seguir o carro.
  4. 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 RaceDesert estiver 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.

  1. Com a RaceDesert completa e salva, clique com o botão direito nela na janela Project → Create → Scene Template From Scene. Isso gera um asset .scenetemplate; mova/renomeie pra Assets/_Project/SceneTemplates/RaceTrack.scenetemplate.
  2. Pra criar uma pista nova: File → New Scene, escolha a aba Scene Templates e selecione RaceTrack — isso abre uma cópia completa da RaceDesert (carro, chevrons, RaceManager, TrackProgress, Canvas/UI, câmera, e também a pista do Deserto em si).
  3. Salve essa cópia como RaceForest (ou RaceIce) em Assets/_Project/Scenes/.
  4. 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. O TrackValidZone (com o TrackBoundary, seção 3.4) também é redesenhado (o polígono/spline muda de forma), mas o script continua o mesmo — só ajuste o campo Grip (seção 3.8) pro valor daquela pista (o total de waypoints é contado sozinho pelo TrackProgress, seção 3.7).
  5. Só na RaceForest: adicione as PuddleZone (seção 3.9), o preset de partícula de respingo (Fase 1 §1.5) e a chuva ambiente (seção 3.11).
  6. Só na RaceIce: troque o preset de partícula do CarTrailParticles pra gelo/neve (Fase 1 §1.5) — sem poça, sem chuva.
  7. Tudo que não foi apagado (Car, ChevronGuide, RaceManager, TrackProgress, Canvas com 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óprio BackgroundSprite.

✅ Checkpoint da Fase 3​

  • Dar Play, guiar o carro para fora dos limites do TrackValidZone → RaceActive vira false e Crashed vira true (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_XX em ordem → TrackProgress.Instance.ProgressNormalized sobe em degraus no Inspector (visível ao selecionar o GameObject TrackProgress durante o Play).
  • Na RaceDesert, CarController.CurrentGrip fica sempre em 1 durante o Play (visível no Inspector, componente Car).
  • Depois da seção 3.12: dar Play na RaceForest e cruzar uma poça → CurrentGrip cai pro valor da PuddleZone, InPuddle vira true, e o carro visivelmente "escorrega" antes de voltar a obedecer o cursor ao sair dela; na RaceIce, CurrentGrip fica sempre no valor baixo configurado na seção 3.8, o traçado inteiro.

Problemas comuns​

  • OnTriggerExit2D nunca dispara: confirme que o Car tem 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 = Continuous no Rigidbody2D do carro (Rigidbody2D → Collision Detection).
  • ProgressNormalized nunca passa de um certo valor: confira se os Waypoint_XX estão na ordem do sentido da corrida dentro de TrackWaypoints na Hierarchy (o Index vem dessa ordem, via GetSiblingIndex()) — uma porta fora de ordem é ignorada pelo guard if (index <= lastWaypointIndex).
  • Carro nunca parece derrapar na Floresta/Gelo: confira se CarTerrainSensor está no Car (não na zona) e se o campo Track Boundary está ligado ao TrackValidZone da cena — sem isso, CurrentGrip nunca é setado e fica no valor padrão (1) do CarController.

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​

  1. Hierarchy → botão direito → UI → Canvas (Unity cria automaticamente um EventSystem junto — deixe como está).
  2. No Canvas, ajuste Canvas Scaler → UI Scale Mode para Scale With Screen Size, com Reference Resolution tipo 1920 x 1080 (evita a UI ficar em tamanhos diferentes em telas diferentes).
  3. Dentro do Canvas, botão direito → UI → Text - TextMeshPro (se for a primeira vez, aceite importar o "TMP Essentials").
  4. Renomeie para TimerText.
  5. Posicione no canto superior (ex.: Anchor = top-center, Pos Y perto do topo).
  6. 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}";
}
}
  1. Arraste o script no TimerText (ou num objeto separado, se preferir organizar assim).
  2. No Inspector, arraste o próprio componente TMP_Text do TimerText pro campo Timer Text.

4.3 Menu de colisão: "Você Bateu"​

Objetivo: quando RaceManager.Crashed vira true (Fase 3, seção 3.6), pausar a corrida e mostrar um menu esperando o jogador apertar R (tentar de novo) ou Esc (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:

  1. No mesmo Canvas da seção 4.1, crie um painel CrashPanel (UI → Panel).
  2. Dentro dele, um texto TextMeshPro CrashMessageText com o conteúdo: Você Bateu!\nAperte ESC para voltar ao grid ou R para tentar novamente.
  3. Deixe CrashPanel desativado 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();
}
}
  1. Arraste o script num GameObject ativo da cena (ex.: no Canvas) — não no próprio CrashPanel: ele começa desativado, e um script em objeto desativado não roda Update, então o painel nunca apareceria.
  2. No Inspector, ligue o campo Crash Panel ao GameObject CrashPanel criado acima.

QuitToMainMenu() carrega a cena MainMenu, que só é criada na Fase 6 (Parte 13). Até lá, testar o Esc nesta 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:

ArquivoTamanhoO que é
speedbar_frame.png640 × 112Moldura: contorno marrom, trilho escuro e sombra. Fica atrás.
speedbar_fill.png600 × 64Preenchimento 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.png600 × 64O mesmo preenchimento em branco, caso prefira uma cor só, definida pelo campo Color da Image.
  1. Copie os três para Assets/_Project/Sprites/UI/.
  2. 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, deixe Filter Mode = Bilinear.

2. Montar a barra na UI:

  1. No mesmo Canvas da seção 4.1, crie uma Image chamada SpeedBarBackground (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 Width e Height mantendo a proporção 640 × 112 (ex.: 320 × 56).
  2. Como filha dela, crie outra Image chamada SpeedBarFill.

    • Source Image = speedbar_fill.
    • Image Type = Filled, Fill Method = Horizontal, Fill Origin = Left.
    • Deixe Fill Amount = 1 por enquanto, para ver a barra cheia enquanto ajusta.
  3. Encaixe o preenchimento no trilho pelas âncoras do RectTransform do SpeedBarFill:

    • Anchors Min = (0.03125, 0.25) e Anchors Max = (0.96875, 0.8214);
    • Left, Top, Right e Bottom todos em 0.

    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;
}
}
  1. Arraste o script em qualquer GameObject da cena (ex.: no próprio SpeedBarFill).
  2. No Inspector, ligue Car ao GameObject Car da cena e Speed Fill Image ao componente Image do SpeedBarFill.

4.5 Indicador de % da pista percorrida​

Objetivo: mostrar quanto da pista o jogador já percorreu, lendo o TrackProgress criado na Fase 3 (Parte 10 §3.7).

1. Criar o texto na UI:

  1. No mesmo Canvas, crie um texto TextMeshPro TrackProgressText (posição sugerida: ao lado do TimerText, 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}%";
}
}
  1. Arraste o script no TrackProgressText (ou num objeto separado).
  2. No Inspector, ligue Progress Text ao próprio componente TMP_Text do TrackProgressText.

✅ 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 SpeedBarFill sobe e desce acompanhando ActualSpeedNormalized (0–100%). Na RaceDesert isso acompanha o cursor de perto; na RaceForest/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) → TrackProgressText sobe em degraus (ex.: 0% → 8% → 16%...).
  • Guiar o carro pra fora da pista → o cronômetro para e o CrashPanel aparece com a mensagem "Você Bateu! Aperte ESC para voltar ao grid ou R para tentar novamente.".
  • Apertar R com o menu aberto → a pista recarrega do zero (mesmo efeito do reinício automático antigo).
  • Apertar Esc com o menu aberto → tenta carregar MainMenu (só funciona de fato a partir da Fase 6, ver nota acima).

Problemas comuns​

  • SpeedBarFill não se move: confira se Image Type = Filled está marcado — numa Image normal, fillAmount nã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 em 0, e a proporção da SpeedBarBackground precisa continuar 640 × 112.
  • TrackProgressText fica sempre em 0%: confira se os Waypoint_XX têm o script TrackWaypoint e um Box Collider 2D com Is Trigger ✅ cobrindo a largura da pista, e se o carro tem a tag Car (Fase 3, seções 3.5 e 3.7). Se o % sobe fora de ordem, a ordem dos filhos dentro de TrackWaypoints na 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​

  1. Acesse console.firebase.google.com e faça login com sua conta Google.
  2. Add project → nome RallySurvive → siga o assistente (pode desativar Google Analytics por enquanto, não é necessário pro MVP).
  3. 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.
  4. 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_OPERATION na versão REST).

5.2 Registrar o app no Firebase​

  1. 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.
  2. Dê um apelido ao app (ex.: RallySurvive Web) e finalize o cadastro. Não é preciso hospedar no Firebase Hosting — só precisamos das credenciais.
  3. O console vai mostrar um objeto firebaseConfig com campos como apiKey e projectId. 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).
  4. 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.

  1. Baixe o Firebase Unity SDK em firebase.google.com/download/unity.
  2. No Unity, Assets → Import Package → Custom Package, selecione o .unitypackage de Firestore (dentro do SDK baixado — vem separado por produto: Auth, Firestore, Analytics, etc.).
  3. Importe também o pacote de Authentication (vamos usar login anônimo, mais simples pra MVP).
  4. Coloque o google-services.json (ou .plist) na raiz de Assets/ (o SDK vai processá-lo automaticamente no build).
  5. 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;
}
}
  1. Crie um GameObject vazio FirebaseBootstrap na 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étodos SubmitTime/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 (o RaceManager, 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 um idToken (token de autenticação) e um localId (equivalente ao uid).
  • Firestore REST API (firestore.googleapis.com) — lê/escreve documentos, autenticado com o idToken acima no header Authorization: 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}");
}
}
  1. Crie um GameObject vazio FirebaseBootstrapWebGL na sua primeira cena carregada e arraste o script — mesma posição que o FirebaseBootstrap teria na versão SDK nativo.
  2. Substitua SUA_API_KEY_AQUI/SEU_PROJECT_ID_AQUI pelos valores reais do console (a apiKey do 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).
  3. Em aberto: o idToken expira em 1h (expiresIn na resposta). Para uma corrida de alguns minutos isso não é problema; se sessões mais longas passarem a importar, dá pra trocar o refreshToken por um novo idToken via securetoken.googleapis.com/v1/token quando 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.Json via 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:

  1. Na cena de corrida, crie um GameObject vazio FinishLine, posicionado no ponto final da pista.
  2. Adicione um Polygon Collider 2D (ou Box Collider 2D, se a linha de chegada for reta) e marque Is Trigger.
  3. 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 do OnCarLeftTrack() (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, OnTriggerEnter2D poderia 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 SubmitFinishedTime troca só de classe: LeaderboardService.SubmitTime(...) no lugar de LeaderboardServiceWebGL.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 trackId no RaceManager, 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.Finished vira true, mostra um painel com o tempo final, um campo pra digitar o nome antes de chamar SubmitFinishedTime, 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):

  1. Crie um painel FinishPanel (UI → Panel), desativado por padrão.
  2. Dentro dele:
    • Um texto TextMeshPro FinalTimeText, mesmo formato do TimerText (Fase 4, seção 4.1) — mostra o tempo final parado, já que o TimerText continua exibindo o mesmo valor por trás do painel.
    • Um TMP_InputField chamado NameInputField (campo Character Limit = 5 no Inspector) e um Button chamado ConfirmButton com um texto tipo "Confirmar".
    • Um Button chamado RetryButton com o texto "Repetir Pista".
    • Um Button chamado MainMenuButton com o texto "Voltar ao Menu".

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}";
}
}

RetryButton e MainMenuButton chamam diretamente RaceManager.RetryRace()/QuitToMainMenu() (já existentes desde a Fase 3, seção 3.6, usados pelo CrashMenuUI) — nenhum método novo no RaceManager é necessário. Como ficam interagíveis o tempo todo (diferente do ConfirmButton, 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 FinishPanel aparece 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 ConfirmButton fica 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 do R no CrashMenuUI, Fase 4 §4.3), sem enviar tempo ao leaderboard.
  • Sem confirmar nome nenhum, apertar MainMenuButton → tenta carregar MainMenu (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 o playerName digitado.
  • 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_DENIED ao escrever: confira se FirebaseBootstrapWebGL.IsReady já é true (login anônimo concluído) antes de chamar SubmitTime — adicione um log pra confirmar a ordem de execução.
  • Erro 400 "Invalid JSON payload" no PATCH: normalmente é timeSeconds sendo formatado com vírgula em vez de ponto decimal (problema de CultureInfo do sistema) — confira se o ToString(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 o index.html direto 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 de RaceDesert conforme 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​

  1. Em Assets/_Project/Scenes/, crie uma nova cena MainMenu.
  2. Em File → Build Settings, adicione MainMenu à lista de cenas e arraste-a para o topo (índice 0) — é a cena que carrega primeiro quando o jogo abre.
  3. Mova (ou recrie) o GameObject FirebaseBootstrapWebGL (Fase 5, seção 5.4-WEB) para esta cena — como o script já usa DontDestroyOnLoad, 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​

  1. Hierarchy → UI → Canvas (mesma configuração de Canvas Scaler → Scale With Screen Size usada na Fase 4).
  2. Antes de tudo, dentro do Canvas, crie uma Image chamada MenuBackground, 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.
  3. Crie um painel TrackListPanel com 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 Image filha chamada HoldProgress, Image Type = Filled (Fill Method = Radial 360 ou Horizontal, à escolha), começando com Fill Amount = 0 — é o indicador visual de "quanto falta" pra segurar completar.
  4. Crie um painel LeaderboardPanel ao lado, com:
    • Um texto SelectedTrackText (nome da pista selecionada).
    • Um Scroll View (ou uma Vertical Layout Group simples, já que são poucas linhas) chamado LeaderboardContent, onde as linhas de ranking serão instanciadas.
    • Um texto LoadingIndicator ("Carregando...") e um texto EmptyLeaderboardText ("Nenhum tempo registrado ainda"), ambos desativados por padrão.
  5. Crie um prefab LeaderboardRow em Assets/_Project/Prefabs/UI/: um Horizontal Layout Group com 3 textos TextMeshPro (RankText, NameText, TimeText).
  6. Importe as 3 imagens de background (uma por pista) em Assets/_Project/Sprites/Menu/, com Texture 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​

  1. Crie um GameObject vazio MainMenuController na cena MainMenu e arraste o script.

  2. Preencha a lista tracks com 3 entradas, uma por pista do lançamento:

    TrackIdSceneNameDisplayNameBackgroundSprite
    desertRaceDesertDesertoimagem de fundo do Deserto
    forestRaceForestFlorestaimagem de fundo da Floresta
    iceRaceIceGeloimagem de fundo do Gelo
  3. Arraste o botão correspondente de TrackListPanel (já com o TrackButtonHold da seção 6.4) no campo HoldButton de cada entrada, e a respectiva imagem importada na seção 6.2 no campo BackgroundSprite.

  4. Ligue menuBackground (a Image MenuBackground da seção 6.2), selectedTrackText, leaderboardContent (o Transform do LeaderboardContent), leaderboardRowPrefab (o prefab LeaderboardRow), loadingIndicator e emptyLeaderboardText aos 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 HoldProgress até 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 SelectTrack espera até 10s o login anônimo (FirebaseBootstrapWebGL.IsReady) terminar antes de buscar os tempos; se passou disso, o login falhou — procure o erro Firebase anonymous sign-in failed no Console (normalmente ApiKey errada ou login anônimo não habilitado no Firebase Console).
  • Segurar não carrega a cena: confira se SceneName na lista tracks bate exatamente com o nome da cena em Build Settings (case-sensitive), e se a cena foi de fato adicionada ao Build Settings.
  • Background não troca (ou fica em branco): confira se BackgroundSprite foi preenchido pra cada entrada de tracks e se menuBackground está ligado no Inspector — um Image sem sprite atribuído fica transparente/branco, não dá erro.
  • HoldProgress não preenche: confira se a Image está com Image Type = Filled (uma Image normal ignora fillAmount).
  • Hold completa mesmo arrastando o dedo pra fora do botão: em touchscreens isso pode acontecer se o Canvas não tiver um Graphic Raycaster corretamente configurado — confirme que o EventSystem da cena está ativo e que nenhum outro elemento de UI está sobrepondo o botão e bloqueando o OnPointerExit.
  • 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 de leaderboardContent.

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).

  1. File → Build Settings → selecione WebGL → Switch Platform (só na primeira vez; pode demorar alguns minutos).
  2. Clique Add Open Scenes para incluir sua cena atual.
  3. Em Player Settings → Publishing Settings, configure o Compression Format: itch.io às vezes não envia o header Content-Encoding correto 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).
  4. Clique Build e escolha uma pasta de saída (ex.: Builds/WebGL/).
  5. Teste local: use File → Build and Run (o Unity sobe um servidor local automaticamente) — abrir o index.html direto do disco (file://) não funciona, por restrição de CORS do navegador.

7.1-B Publicar no itch.io​

  1. Compacte o conteúdo da pasta de build em .zip (o index.html precisa estar na raiz do zip, não dentro de uma subpasta).
  2. No itch.io, crie o projeto (ou edite um existente) → Kind of project: HTML.
  3. Faça upload do .zip e marque a caixa "This file will be played in the browser".
  4. Em Embed options, defina a resolução (viewport) igual à configurada em Player Settings → Resolution and Presentation no Unity, pra evitar barras de rolagem/corte.
  5. 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 RaceForest e 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, CarParticles e ChevronGuide sem mudança. Você vai trocar o TrackBoundary/RaceManager por um sistema de checkpoints (novo script CheckpointManager.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 no RaceManager. 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 — os asset_id de cada arquivo estão em Sprites/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 em cars/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_purple e police (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.png e a versão branca ui/speedbar_fill_white.png), feita para Image Type = Filled e montada na Fase 4 §4.4. Não vem do SpriteCook: é desenhada pelo script Sprites/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) e props/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) e deco/ice/ (bonecos de neve, público de roupa de inverno, cabana, lago congelado, arbustos com neve). Decalques de chão em decals/ (marcas de pneu retas/curvas/curtas, mancha de lama, pedrinhas) — usar com alpha reduzido no SpriteRenderer pra 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.
  • 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 Transform ou 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 steeringResponsiveness com 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/InPuddle que já dirige as partículas, Fase 1 §1.5).
  • UI de ranking pós-corrida (ler GetTopTimes e 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.