Fury Hauler — Guia Unity
Parte 2 — Guia de Implementação Unity
Guia fase a fase para implementar o MVP do Fury Hauler em Unity 6, com build Web. Os números vêm dos placeholders da Parte 1 §15 e ficam em assets (
GameConfige os...Def), para dar para balancear sem mexer no código. As regras marcadas como Em aberto que o código precisa decidir de algum jeito viram opções doGameConfigou ficam isoladas num lugar só, para trocar de ideia sem reescrever nada.
Convenções
- Nomes no projeto em inglês, comentários em português. Cada classe começa com
// POR QUE:e// ESTRATÉGIA:, e cada método tem um comentário curto acima dele. - Namespaces:
FuryHauler.World: projeção isométrica, posição no chão, estrada e câmera.FuryHauler.Data: ScriptableObjects de configuração, defesas, inimigos e habilidades.FuryHauler.Truck: o caminhão, sua vida e seus slots.FuryHauler.Trip: o trecho em andamento e o spawn de inimigos.FuryHauler.Enemies: os inimigos.FuryHauler.Defenses: as defesas.FuryHauler.Economy: sucata e alocação (comprar, melhorar, vender).FuryHauler.Abilities: habilidades ativas.FuryHauler.Run: rota, run e save da meta-progressão.FuryHauler.UI: HUD e painéis.
- Campos do Inspector:
[SerializeField] privatecom_camelCase. Dados de ScriptableObject e de save: campos públicoscamelCase. - Textos na tela sem emoji, porque a fonte padrão do TextMeshPro não tem emojis.
Arquitetura em uma página
Chão (lógica 2D, metros) Tela (isométrico)
──────────────────────── ─────────────────
x = distância na estrada, y = lado GroundBody ── IsoProjection.ToScreen ──▶ transform.position
+ ordem de desenho
RunManager ──escolhe nó──▶ TripManager.StartTrip ──Started──▶ EnemySpawner (orçamento por segundo)
▲ │ │ instancia
│ Ended(sucesso?) │ IsDriving ▼
└───────────────────────────┤ Enemy (Biker / Rammer / Boarder / Raider / Boss)
▼ │ TakeDamage ▲ │ Killed
TruckController ──Slots──▶ TruckSlot ──▶ Defense ──────┘ ▼
TruckHealth ◀──dano──── inimigos (mira sozinha) ScrapWallet
│
Mouse ──▶ AllocationController (comprar / melhorar / vender) ◀──────────────────────┘
Teclas/botões ──▶ AbilityController (nitro, óleo)
- Regra de ouro: toda a lógica acontece no "chão", nunca na tela. Cada coisa do jogo tem uma posição
Ground(umVector2em metros):xé quanto já andou na estrada eyé o desvio para o lado (positivo = esquerda do caminhão). Distância, alcance, ângulo de tiro e colisão são calculados ali. Só oGroundBodyconverte essa posição para a tela isométrica. - Por que isso importa aqui: no isométrico, "3 metros" na tela não têm o mesmo tamanho na horizontal e na vertical (a tela é achatada). Se o alcance da metralhadora fosse medido na tela, ele seria maior para os lados do que para frente. Medindo no chão, um círculo de alcance é um círculo de verdade.
- Sem física. Nada cai, quica ou empurra. Não há
Rigidbody2DnemCollider2D: os inimigos se movem porVector2.MoveTowardse os acertos são checagens de distância. Por isso a nota derb.linearVelocity/rb.velocitynão se aplica a este guia.
Fase 0 — Setup do Projeto
Objetivo desta fase: um projeto Unity 6 2D vazio, organizado, com os pacotes certos e pronto para build Web.
0.1 Criar o projeto
- Instale o Unity Hub e uma versão Unity 6 LTS (6000.x) com o módulo Web Build Support (o alvo é navegador, Parte 1 §1).
- No Hub, crie um projeto com New project → Universal 2D e o nome
FuryHauler.- É aqui que o "2.5D" definido na Parte 1 vira decisão técnica: o projeto é 2D. O template 2D já vem com URP 2D, sprites e câmera ortográfica.
- Isométrico não muda o template. O "isométrico" vem de uma conta (
IsoProjection, Fase 1) que converte a posição no chão em posição na tela, e da arte desenhada em perspectiva. - O 2D Tilemap Editor (que tem grade isométrica pronta) não é usado: a estrada é uma faixa reta que rola sem fim, mais simples de montar com sprites soltos reciclados (Fase 1).
0.2 Estrutura de pastas
Crie dentro de Assets/:
Assets/_Project/
Scripts/
World/ (projeção isométrica, estrada, câmera)
Data/ (GameConfig e os ...Def)
Truck/ (caminhão, vida, slots)
Trip/ (trecho e spawn)
Enemies/
Defenses/
Economy/ (sucata e alocação)
Abilities/
Run/ (rota, run, save)
UI/
Resources/ (só o GameConfig)
ScriptableObjects/
Defenses/
Enemies/
Abilities/
Prefabs/
Enemies/
Defenses/
UI/
Scenes/ (Game)
Sprites/
0.3 Pacotes (Window → Package Manager)
| Pacote | Para quê |
|---|---|
| Input System (já vem no Unity 6) | Ler clique do mouse e teclas de atalho. |
| 2D Sprite (vem com o template 2D) | Criar sprites placeholder pelo menu Create → 2D → Sprites (losango isométrico, círculo, quadrado). |
| TextMeshPro (parte do pacote uGUI no Unity 6) | Textos da UI. No primeiro uso, aceite Import TMP Essentials. |
0.4 Cena, camadas de desenho e input
- Crie a cena
GameemAssets/_Project/Scenes/e adicione-a em File → Build Profiles → Scene List. - Sorting Layers (Edit → Project Settings → Tags and Layers → Sorting Layers), nesta ordem:
Chao: estrada e manchas de óleo, sempre por baixo.Mundo: caminhão e inimigos.FX: rastros de tiro e efeitos.
- Em Edit → Project Settings → Player → Other Settings → Active Input Handling, deixe Input System Package (New) ou Both.
- Na Main Camera: Projection = Orthographic, Size = 7, Background com uma cor de areia (ex.:
#D9B47A). Por enquanto o fundo de areia é o deserto inteiro.
✅ Checkpoint da Fase 0
- O projeto abre sem erros no Console.
- A cena
Gameestá na Scene List do Build Profile. - As 3 Sorting Layers existem na ordem
Chao,Mundo,FX. - Apertar Play mostra a tela cor de areia, sem erros.
Problemas comuns
- Não aparece a plataforma Web no Build Profiles: o módulo Web Build Support não foi instalado. No Unity Hub, vá em Installs → (sua versão) → Add modules.
InputSystemnão encontrado nos scripts: o pacote Input System não está instalado, ou o Active Input Handling está em "Input Manager (Old)".
Próxima fase: Mundo Isométrico, com a conta chão → tela, a estrada e a câmera.
Fase 1 — Mundo Isométrico
Objetivo desta fase: um objeto andando sozinho por uma estrada isométrica infinita, com a câmera seguindo.
1.1 A conta chão → tela: IsoProjection
// Caminho: Assets/_Project/Scripts/World/IsoProjection.cs
using UnityEngine;
namespace FuryHauler.World
{
// POR QUE: toda a lógica do jogo acontece no "chão" (metros, sem perspectiva),
// mas a tela é isométrica. Esta classe é o único lugar que sabe converter um
// no outro, então mudar o ângulo da câmera no futuro mexe só aqui.
// ESTRATÉGIA: isométrico clássico 2:1 (o losango de um metro de chão tem o
// dobro de largura em relação à altura). O eixo x do chão (a estrada) sobe
// para a direita na tela, e o eixo y do chão (o lado esquerdo do caminhão)
// sobe para a esquerda. É uma classe "static": não vai em nenhum objeto da
// cena, é só um conjunto de funções.
public static class IsoProjection
{
// Metade da largura e metade da altura, na tela, de 1 metro de chão.
public const float HalfWidth = 0.5f;
public const float HalfHeight = 0.25f;
// Converte um ponto do chão (com uma altura opcional, ex.: o teto do
// caminhão) em posição na tela (coordenadas de mundo da Unity).
public static Vector3 ToScreen(Vector2 ground, float height = 0f)
{
float x = (ground.x - ground.y) * HalfWidth;
float y = (ground.x + ground.y) * HalfHeight + height;
return new Vector3(x, y, 0f);
}
// Converte um ângulo no chão (0 = frente da estrada, 90 = esquerda,
// -90 = direita, 180 = trás) em vetor de comprimento 1.
public static Vector2 Dir(float degrees)
{
float rad = degrees * Mathf.Deg2Rad;
return new Vector2(Mathf.Cos(rad), Mathf.Sin(rad));
}
}
}
1.2 Uma coisa no chão: GroundBody
// Caminho: Assets/_Project/Scripts/World/GroundBody.cs
using UnityEngine;
using UnityEngine.Rendering;
namespace FuryHauler.World
{
// POR QUE: caminhão, inimigos e manchas de óleo vivem no chão. Este componente
// guarda essa posição e, todo frame, coloca o objeto no lugar certo da tela e
// na ordem certa de desenho (no isométrico, o que está "mais perto" da câmera
// precisa ser desenhado por cima).
// ESTRATÉGIA: a ordem de desenho vem de (x + y) no chão: quanto maior, mais
// "para o fundo" da tela, então menor a ordem. Um SortingGroup faz o objeto
// inteiro (com todos os sprites filhos) ser ordenado como um bloco só, o que
// impede, por exemplo, que a roda de um inimigo apareça por cima do caminhão.
[RequireComponent(typeof(SortingGroup))]
public class GroundBody : MonoBehaviour
{
// Precisão da ordenação: 10 = diferenças de 10 cm já mudam a ordem.
private const float SortPrecision = 10f;
[SerializeField] private float _height; // altura acima do chão, em unidades de tela
[SerializeField] private int _sortBias; // empurra a ordem para frente (ex.: saqueador em cima do caminhão)
private SortingGroup _sorting;
// Posição no chão em metros. É uma propriedade, então para mudar só o x
// escreva "Ground += new Vector2(dx, 0)" (não dá para fazer "Ground.x += dx").
public Vector2 Ground { get; set; }
public float Height
{
get => _height;
set => _height = value;
}
// Awake roda uma vez, quando o objeto nasce, antes de qualquer Start.
private void Awake()
{
_sorting = GetComponent<SortingGroup>();
}
// LateUpdate roda depois de todos os Update do frame: aqui a lógica já
// moveu o Ground, e só falta desenhar no lugar novo.
private void LateUpdate()
{
Apply();
}
// Atualiza posição na tela e ordem de desenho agora (útil logo depois de
// teleportar o objeto, para ele não aparecer um frame no lugar antigo).
public void Apply()
{
transform.position = IsoProjection.ToScreen(Ground, _height);
_sorting.sortingOrder = _sortBias - Mathf.RoundToInt((Ground.x + Ground.y) * SortPrecision);
}
}
}
A
sortingOrderaceita valores de -32768 a 32767. Com precisão 10, isso cobre uns 3 km de estrada, bem mais que um trecho (900 m, Parte 1 §15). Se um trecho ficar maior que isso, reduzaSortPrecision.
1.3 A estrada: RoadView
// Caminho: Assets/_Project/Scripts/World/RoadView.cs
using System.Collections.Generic;
using UnityEngine;
namespace FuryHauler.World
{
// POR QUE: a estrada parece infinita, mas criar 900 metros de sprites seria
// desperdício. Só os pedaços perto da câmera precisam existir.
// ESTRATÉGIA: a estrada é uma fila de quadrados de chão (largura × largura).
// No isométrico, um quadrado de chão vira um losango, então o sprite
// "Isometric Diamond" serve de placeholder. Um número fixo de pedaços é
// criado uma vez e reposicionado conforme o objeto seguido avança
// (reciclagem, sem criar nem destruir nada durante o jogo).
public class RoadView : MonoBehaviour
{
[SerializeField] private GroundBody _follow; // normalmente o caminhão
[SerializeField] private SpriteRenderer _tilePrefab; // losango isométrico
[SerializeField] private float _roadWidth = 12f; // metros; igual a 2 × GameConfig.roadHalfWidth
[SerializeField] private int _tilesBehind = 3;
[SerializeField] private int _tilesAhead = 6;
private readonly List<SpriteRenderer> _tiles = new();
private int _firstIndex = int.MinValue;
// Start roda uma vez, no primeiro frame em que o objeto está ativo.
private void Start()
{
int count = _tilesBehind + _tilesAhead + 1;
for (int i = 0; i < count; i++)
{
SpriteRenderer tile = Instantiate(_tilePrefab, transform);
FitToGround(tile);
_tiles.Add(tile);
}
}
// Estica o sprite para ocupar exatamente um quadrado de chão de
// _roadWidth × _roadWidth metros, qualquer que seja o tamanho da imagem.
private void FitToGround(SpriteRenderer tile)
{
Vector3 size = tile.sprite.bounds.size;
float targetWidth = _roadWidth * IsoProjection.HalfWidth * 2f;
float targetHeight = _roadWidth * IsoProjection.HalfHeight * 2f;
tile.transform.localScale = new Vector3(targetWidth / size.x, targetHeight / size.y, 1f);
}
// Quando o objeto seguido passa para o próximo pedaço, todos os pedaços
// são redistribuídos em volta dele.
private void LateUpdate()
{
if (_follow == null) return;
int center = Mathf.FloorToInt(_follow.Ground.x / _roadWidth);
int first = center - _tilesBehind;
if (first == _firstIndex) return;
_firstIndex = first;
for (int i = 0; i < _tiles.Count; i++)
{
int index = first + i;
Vector2 tileCenter = new Vector2((index + 0.5f) * _roadWidth, 0f);
_tiles[i].transform.position = IsoProjection.ToScreen(tileCenter);
}
}
}
}
1.4 A câmera: CameraFollow
// Caminho: Assets/_Project/Scripts/World/CameraFollow.cs
using UnityEngine;
namespace FuryHauler.World
{
// POR QUE: o caminhão nunca para, então a câmera precisa ir junto, com um
// pouco de folga à frente para o jogador ver quem está chegando.
// ESTRATÉGIA: segue um ponto no chão um pouco à frente do alvo, com
// suavização exponencial (chega rápido quando está longe e devagar quando
// está perto, sem tremer). DefaultExecutionOrder(100) faz este LateUpdate
// rodar depois do LateUpdate do GroundBody, que já posicionou o alvo.
[DefaultExecutionOrder(100)]
public class CameraFollow : MonoBehaviour
{
[SerializeField] private GroundBody _target;
[SerializeField] private Vector2 _groundOffset = new Vector2(3f, 0f); // metros à frente
[SerializeField] private float _sharpness = 6f;
private void LateUpdate()
{
if (_target == null) return;
Vector3 goal = IsoProjection.ToScreen(_target.Ground + _groundOffset);
goal.z = transform.position.z; // a câmera 2D fica em z = -10; não mexer nisso
float t = 1f - Mathf.Exp(-_sharpness * Time.deltaTime);
transform.position = Vector3.Lerp(transform.position, goal, t);
}
}
}
1.5 Um teste que anda sozinho: GroundDriverTest
Script temporário, só para esta fase. Na Fase 2 o TruckController faz isso de verdade e este arquivo pode ser apagado.
// Caminho: Assets/_Project/Scripts/World/GroundDriverTest.cs
using UnityEngine;
namespace FuryHauler.World
{
// POR QUE: testar estrada e câmera antes de existir o caminhão.
// ESTRATÉGIA: empurra o GroundBody para frente no eixo x do chão.
[RequireComponent(typeof(GroundBody))]
public class GroundDriverTest : MonoBehaviour
{
[SerializeField] private float _speed = 10f; // metros por segundo
private GroundBody _body;
private void Awake()
{
_body = GetComponent<GroundBody>();
}
// Update roda uma vez por frame; Time.deltaTime é o tempo desde o frame
// anterior, então "velocidade × deltaTime" anda certo em qualquer FPS.
private void Update()
{
_body.Ground += new Vector2(_speed * Time.deltaTime, 0f);
}
}
}
1.6 Montar a cena
- Sprites placeholder: em
Assets/_Project/Sprites/, clique com o botão direito → Create → 2D → Sprites → Isometric Diamond (nomeDiamond), e também Square e Circle. - Prefab do pedaço de estrada: arraste o
Diamondpara a cena, renomeie paraRoadTile, pinte de cinza-escuro (#4A4038), Sorting Layer = Chao, e arraste paraPrefabs/para virar prefab. Apague da cena. - Objeto de teste: crie um GameObject vazio
Truck, adicione GroundBody (oSortingGroupentra junto) e GroundDriverTest. NoSortingGroup, Sorting Layer = Mundo. Como filho, adicione um spriteSquarechamadoBody, cor de ferrugem (#8C3B1E), escala(1.6, 0.8, 1). - Estrada: GameObject vazio
Roadcom RoadView:Follow=Truck,Tile Prefab=RoadTile. - Câmera: adicione CameraFollow na
Main Camera, comTarget=Truck.
✅ Checkpoint da Fase 1
- No Play, o quadrado do caminhão anda na diagonal (subindo para a direita) e a câmera vai junto.
- A estrada cinza nunca acaba e não tem buracos entre os losangos.
- Na janela Hierarchy, o número de
RoadTilefica fixo (10) durante o Play.
Problemas comuns
- Buracos ou sobreposição entre os losangos: o sprite tem borda transparente. Abra o sprite no Sprite Editor e confira se o losango encosta nas bordas da imagem; o
FitToGroundestica a imagem inteira, não só a parte visível. - A câmera treme: confira se o
CameraFollowestá mesmo com[DefaultExecutionOrder(100)]. Sem isso, às vezes ela segue a posição do frame anterior.
Próxima fase: Caminhão e Trecho, com o caminhão de verdade, vida, slots e o fim do trecho.
Fase 2 — Caminhão e Trecho
Objetivo desta fase: o caminhão anda sozinho do começo ao fim de um trecho de 900 m, tem vida e 6 slots marcados, e o jogo sabe quando o trecho acabou.
2.1 Os números do jogo: GameConfig
// Caminho: Assets/_Project/Scripts/Data/GameConfig.cs
using UnityEngine;
namespace FuryHauler.Data
{
// POR QUE: os números do jogo ainda são placeholders (Parte 1 §15). Num
// asset, dá para mudar no Inspector, até durante o Play, sem tocar em código.
// ESTRATÉGIA: um ScriptableObject é um "arquivo de dados" da Unity, criado
// pelo menu Create. Como existe um só, ele fica em Resources/ e é carregado
// por nome na primeira vez que alguém pede GameConfig.I.
[CreateAssetMenu(menuName = "Fury Hauler/Game Config", fileName = "GameConfig")]
public class GameConfig : ScriptableObject
{
[Header("Caminhão (Parte 1 §3)")]
public float truckMaxHp = 500f;
public float truckSpeed = 10f; // metros por segundo
[Range(0f, 1f)] public float maxDamageReduction = 0.6f;
[Header("Trecho (Parte 1 §8)")]
public float tripLength = 900f; // metros
public float roadHalfWidth = 6f; // metros de cada lado do centro
public float enemyDespawnBehind = 35f; // inimigo mais atrás que isso some sem dar sucata
[Header("Spawn de inimigos (Parte 1 §15)")]
public float spawnBudgetBase = 0.25f; // pontos por segundo
public float spawnBudgetPerDifficulty = 0.1f;
public float spawnBehindMin = 18f;
public float spawnBehindMax = 24f;
[Range(0f, 1f)] public float bossSpawnAtProgress = 0.3f;
[Header("Sucata (Parte 1 §5)")]
public int startingScrap = 100;
[Range(0f, 1f)] public float sellRefund = 0.5f;
[Header("Rota (Parte 1 §8)")]
public int routeLayers = 6; // inclui o trecho do chefe
public int minNodesPerLayer = 2;
public int maxNodesPerLayer = 3;
public int maxRisk = 2;
public float scrapBonusPerRisk = 0.25f;
[Header("Regras Em aberto (placeholders)")]
public bool carryTruckHpBetweenTrips = true; // Parte 1 §3
public bool bossBlocksArrival = true; // Parte 1 §7
private static GameConfig _instance;
// Acesso de qualquer script: GameConfig.I.truckSpeed
public static GameConfig I
{
get
{
if (_instance == null)
{
_instance = Resources.Load<GameConfig>("GameConfig");
if (_instance == null)
Debug.LogError("GameConfig não encontrado. Crie em Assets/_Project/Resources/GameConfig.asset.");
}
return _instance;
}
}
}
}
2.2 Um slot no caminhão: TruckSlot (versão 1)
Esta versão só sabe onde o slot fica e para onde ele pode atirar. Na Fase 4 ela é substituída pela versão final, que também guarda a defesa montada.
// Caminho: Assets/_Project/Scripts/Truck/TruckSlot.cs
using FuryHauler.World;
using UnityEngine;
namespace FuryHauler.Truck
{
// POR QUE: a decisão central do jogador é "qual defesa em qual slot"
// (Parte 1 §3). Cada slot tem uma posição no caminhão e um ângulo de tiro.
// ESTRATÉGIA: o slot guarda um deslocamento no chão em relação ao centro do
// caminhão, então sua posição real é sempre "caminhão + deslocamento". O
// ângulo de tiro é um leque: direção central + abertura (360 = qualquer
// lado). DefaultExecutionOrder(50) faz o slot se posicionar depois do
// GroundBody do caminhão, para não ficar um frame atrasado.
[DefaultExecutionOrder(50)]
public class TruckSlot : MonoBehaviour
{
[SerializeField] private string _label = "Teto";
[SerializeField] private Vector2 _offset; // metros a partir do centro do caminhão
[SerializeField] private float _height = 0.6f; // altura na tela (em cima do caminhão)
[SerializeField] private float _facingDegrees; // 0 frente, 90 esquerda, -90 direita, 180 trás
[SerializeField, Range(10f, 360f)] private float _arcDegrees = 360f;
[SerializeField] private SpriteRenderer _marker;
[SerializeField] private Color _idleColor = new Color(1f, 1f, 1f, 0.35f);
[SerializeField] private Color _sabotagedColor = Color.red;
private int _saboteurs;
public TruckController Truck { get; private set; }
public string Label => _label;
public float FacingDegrees => _facingDegrees;
public float ArcDegrees => _arcDegrees;
public bool Sabotaged => _saboteurs > 0;
public Vector2 Ground => Truck.Body.Ground + _offset;
// Chamado pelo caminhão no Awake dele: o slot passa a saber a quem pertence.
public void Bind(TruckController truck)
{
Truck = truck;
}
// Diz se um ponto no chão está dentro do leque de tiro deste slot.
public bool CanTarget(Vector2 point)
{
if (_arcDegrees >= 360f) return true;
Vector2 toPoint = point - Ground;
float angle = Vector2.SignedAngle(IsoProjection.Dir(_facingDegrees), toPoint);
return Mathf.Abs(angle) <= _arcDegrees * 0.5f;
}
// Saqueadores (Fase 3) avisam quando sobem e quando saem do slot.
public void AddSaboteur() => _saboteurs++;
public void RemoveSaboteur() => _saboteurs = Mathf.Max(0, _saboteurs - 1);
private void LateUpdate()
{
if (Truck == null) return;
transform.position = IsoProjection.ToScreen(Ground, _height);
if (_marker != null) _marker.color = Sabotaged ? _sabotagedColor : _idleColor;
}
}
}
2.3 A vida do caminhão: TruckHealth
// Caminho: Assets/_Project/Scripts/Truck/TruckHealth.cs
using System;
using FuryHauler.Data;
using UnityEngine;
namespace FuryHauler.Truck
{
// POR QUE: vida zero acaba a run (Parte 1 §10). Todo dano dos inimigos
// passa por aqui, então a redução das blindagens (Fase 4) fica num lugar só.
// ESTRATÉGIA: eventos C# (event Action) avisam a UI e o TripManager quando a
// vida muda ou chega a zero, sem eles precisarem checar todo frame. O sprite
// do caminhão pisca em vermelho ao levar dano, porque os tiros inimigos são
// instantâneos (placeholder, Parte 1 §7) e o jogador precisa perceber.
public class TruckHealth : MonoBehaviour
{
[SerializeField] private SpriteRenderer _bodyRenderer;
[SerializeField] private Color _hitColor = new Color(1f, 0.3f, 0.3f);
private TruckController _truck;
private Color _baseColor;
private float _flashTimer;
public float Max => GameConfig.I.truckMaxHp;
public float Current { get; private set; }
public bool IsDead => Current <= 0f;
public event Action Changed;
public event Action Died;
// Quanto do dano é ignorado (0 a 1). Na Fase 4 esta linha passa a somar as blindagens.
public float DamageReduction => 0f;
private void Awake()
{
_truck = GetComponent<TruckController>();
Current = Max;
if (_bodyRenderer != null) _baseColor = _bodyRenderer.color;
}
// Volta para a vida cheia (começo de run).
public void ResetFull()
{
Current = Max;
Changed?.Invoke();
}
// Aplica dano, já descontando a redução das blindagens.
public void TakeDamage(float amount)
{
if (IsDead || amount <= 0f) return;
Current = Mathf.Max(0f, Current - amount * (1f - DamageReduction));
_flashTimer = 0.1f;
Changed?.Invoke();
if (IsDead) Died?.Invoke();
}
// Faz o sprite voltar à cor normal depois da piscada.
private void Update()
{
if (_bodyRenderer == null) return;
_flashTimer -= Time.deltaTime;
_bodyRenderer.color = _flashTimer > 0f ? _hitColor : _baseColor;
}
}
}
O campo
_truckainda não é usado: ele entra na conta da blindagem na Fase 4. A Unity mostra um aviso amarelo de "campo não usado", que pode ser ignorado até lá.
2.4 O caminhão: TruckController
// Caminho: Assets/_Project/Scripts/Truck/TruckController.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.World;
using UnityEngine;
namespace FuryHauler.Truck
{
// POR QUE: o caminhão anda sozinho e o jogador nunca o controla (Parte 1 §3).
// Ele é o centro de tudo: inimigos o perseguem, defesas montam nele e a
// câmera o segue.
// ESTRATÉGIA: velocidade constante no eixo x do chão. O multiplicador de
// velocidade existe para o nitro (Fase 6). Os slots são encontrados
// automaticamente entre os filhos, então basta arrastar slots para dentro
// do caminhão na Hierarchy.
[RequireComponent(typeof(GroundBody), typeof(TruckHealth))]
public class TruckController : MonoBehaviour
{
private readonly List<TruckSlot> _slots = new();
public GroundBody Body { get; private set; }
public TruckHealth Health { get; private set; }
public IReadOnlyList<TruckSlot> Slots => _slots;
public bool IsDriving { get; set; }
public float SpeedMultiplier { get; set; } = 1f;
public float Speed => GameConfig.I.truckSpeed * SpeedMultiplier;
public Vector2 Ground => Body.Ground;
public float Distance => Body.Ground.x;
private void Awake()
{
Body = GetComponent<GroundBody>();
Health = GetComponent<TruckHealth>();
GetComponentsInChildren(_slots);
foreach (TruckSlot slot in _slots) slot.Bind(this);
}
// Anda para frente enquanto o trecho estiver rolando.
private void Update()
{
if (!IsDriving) return;
Body.Ground += new Vector2(Speed * Time.deltaTime, 0f);
}
// Volta para o começo da estrada (início de cada trecho).
public void ResetForTrip()
{
Body.Ground = Vector2.zero;
SpeedMultiplier = 1f;
Body.Apply();
}
}
}
2.5 O trecho: TripManager
// Caminho: Assets/_Project/Scripts/Trip/TripManager.cs
using System;
using FuryHauler.Data;
using FuryHauler.Truck;
using UnityEngine;
namespace FuryHauler.Trip
{
// Tudo que muda de um trecho para outro (vem do nó do mapa de rota, Fase 7).
// [Serializable] faz a struct aparecer no Inspector.
[Serializable]
public struct TripSettings
{
public string label;
public int difficulty; // mais alto = mais inimigos por segundo
public bool hasBoss;
public float scrapMultiplier; // trechos de mais risco pagam mais
}
// POR QUE: alguém precisa dizer "o trecho começou" e "o trecho acabou, com
// sucesso ou não". Spawner, sucata, habilidades e a run escutam isso.
// ESTRATÉGIA: o trecho termina com sucesso quando o caminhão passa do
// comprimento do trecho e nada está "segurando" a chegada. O chefe (Fase 3)
// segura a chegada enquanto está vivo, através de um contador genérico de
// bloqueios, para o TripManager não precisar conhecer o chefe. O campo
// _startOnPlay permite testar as Fases 2 a 6 sem a run da Fase 7.
public class TripManager : MonoBehaviour
{
public static TripManager Current { get; private set; }
[SerializeField] private TruckController _truck;
[SerializeField] private bool _startOnPlay = true; // desligar na Fase 7
[SerializeField] private TripSettings _testSettings = new TripSettings
{
label = "Trecho de teste",
difficulty = 2,
hasBoss = false,
scrapMultiplier = 1f,
};
private int _arrivalBlockers;
public TruckController Truck => _truck;
public TripSettings Settings { get; private set; }
public bool IsRunning { get; private set; }
public float Length => GameConfig.I.tripLength;
public float Progress01 => Mathf.Clamp01(_truck.Distance / Length);
public event Action<TripSettings> Started;
public event Action<bool> Ended; // true = chegou ao fim do trecho
private void Awake()
{
Current = this;
}
private void Start()
{
_truck.Health.Died += OnTruckDied;
if (_startOnPlay) StartTrip(_testSettings);
}
// Começa um trecho: caminhão no início, andando, e todo mundo avisado.
public void StartTrip(TripSettings settings)
{
Settings = settings;
_arrivalBlockers = 0;
_truck.ResetForTrip();
_truck.IsDriving = true;
IsRunning = true;
Started?.Invoke(settings);
}
// Algo (o chefe) impede o trecho de terminar enquanto existir.
public void AddArrivalBlocker() => _arrivalBlockers++;
public void RemoveArrivalBlocker() => _arrivalBlockers = Mathf.Max(0, _arrivalBlockers - 1);
// Checa a chegada todo frame.
private void Update()
{
if (!IsRunning) return;
if (_truck.Distance >= Length && _arrivalBlockers == 0) End(true);
}
private void OnTruckDied()
{
if (IsRunning) End(false);
}
// Para o caminhão e avisa o resultado.
private void End(bool success)
{
IsRunning = false;
_truck.IsDriving = false;
Debug.Log(success ? "Trecho concluído." : "Caminhão destruído.");
Ended?.Invoke(success);
}
}
}
2.6 Montar na cena
- GameConfig: em
Assets/_Project/Resources/, Create → Fury Hauler → Game Config, com o nome exatoGameConfig. - Caminhão: no objeto
Truckda Fase 1, remova o GroundDriverTest (pode apagar o script) e adicione TruckController (oTruckHealthentra junto). Em TruckHealth,Body Renderer= o spriteBody. - Slots: crie 6 filhos vazios do
Truck, cada um com TruckSlot e um filho spriteCircle(escala0.3, Order in Layer = 5) ligado emMarker. Valores placeholder (Parte 1 §3):
| Nome | Label | Offset | Facing | Arc |
|---|---|---|---|---|
Slot_Teto | Teto | (0.5, 0) | 0 | 360 |
Slot_EsqFrente | Esquerda frente | (1.5, 1.2) | 90 | 180 |
Slot_EsqTras | Esquerda trás | (-1.5, 1.2) | 90 | 180 |
Slot_DirFrente | Direita frente | (1.5, -1.2) | -90 | 180 |
Slot_DirTras | Direita trás | (-1.5, -1.2) | -90 | 180 |
Slot_Traseira | Traseira | (-3, 0) | 180 | 180 |
- Trecho: GameObject vazio
Tripcom TripManager,Truck=Truck,Start On Playligado. - Para testar rápido, diminua
Trip LengthnoGameConfigpara100durante o teste (e volte para900depois).
✅ Checkpoint da Fase 2
- No Play, o caminhão anda sozinho, e os 6 marcadores dos slots vão junto, em volta dele.
- Com
Trip Length = 100, depois de ~10 s o caminhão para e o Console mostra "Trecho concluído." - O Console não mostra erros (a vida e os slots só serão testados de verdade quando houver inimigos, na Fase 3).
Problemas comuns
GameConfig não encontrado: o asset precisa estar numa pasta chamada exatamenteResourcese se chamar exatamenteGameConfig.- Os marcadores ficam parados no começo da estrada: o
TruckSlotprecisa ser filho doTruck; o caminhão só encontra os slots que estão dentro dele.
Próxima fase: Inimigos, com motoqueiros, carros-bomba, buggies, saqueadores, o chefe e o spawn por orçamento.
Fase 3 — Inimigos
Objetivo desta fase: gangues aparecendo atrás do caminhão, perseguindo e atacando, cada tipo com seu comportamento, e o chefe segurando a chegada a B.
3.1 Dados de um inimigo: EnemyDef
// Caminho: Assets/_Project/Scripts/Data/EnemyDef.cs
using FuryHauler.Enemies;
using UnityEngine;
namespace FuryHauler.Data
{
// POR QUE: os números de cada inimigo são placeholders (Parte 1 §15).
// Separar dados (este asset) de comportamento (a classe do inimigo) deixa
// balancear sem abrir código e criar variações (ex.: "motoqueiro blindado")
// só duplicando o asset.
// ESTRATÉGIA: um campo genérico "spawnDef" serve tanto para o buggy (que
// solta saqueadores) quanto para o chefe (que chama motoqueiros).
[CreateAssetMenu(menuName = "Fury Hauler/Enemy", fileName = "Enemy")]
public class EnemyDef : ScriptableObject
{
public string displayName = "Motoqueiro";
public Enemy prefab;
[Header("Status")]
public float maxHp = 30f;
public float speed = 16f; // m/s; precisa ser maior que a do caminhão para alcançá-lo
public float damage = 4f; // por ataque (ou por segundo, no saqueador)
public float fireInterval = 1f; // segundos entre ataques
public float contactRadius = 1.8f; // carro-bomba: distância em que explode
public bool isVehicle = true; // o arpão e o óleo só afetam veículos
public bool stunnable = true;
[Header("Reforços (buggy e chefe)")]
public EnemyDef spawnDef;
public int spawnCount = 2;
public float spawnInterval = 2f;
[Header("Recompensa e spawn")]
public int scrapReward = 8;
public int spawnCost = 1; // pontos do orçamento de spawn
public int minDifficulty; // só aparece em trechos com dificuldade >= isto
}
}
3.2 A base de todo inimigo: Enemy
// Caminho: Assets/_Project/Scripts/Enemies/Enemy.cs
using System;
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Truck;
using FuryHauler.World;
using UnityEngine;
namespace FuryHauler.Enemies
{
// POR QUE: todos os inimigos têm vida, levam dano, podem ser atordoados,
// dão sucata ao morrer e somem se ficarem para trás. Só o jeito de se mover
// e atacar muda de um para outro.
// ESTRATÉGIA: classe "abstract" com o comum; cada tipo herda dela e
// implementa só o Tick (o comportamento). Uma lista estática All guarda os
// inimigos vivos, para as defesas procurarem alvos sem usar física. Quem
// morre avisa pelo evento estático Killed (a sucata da Fase 5 escuta).
[RequireComponent(typeof(GroundBody))]
public abstract class Enemy : MonoBehaviour
{
public static readonly List<Enemy> All = new();
public static event Action<Enemy> Killed;
[SerializeField] private SpriteRenderer _renderer;
[SerializeField] private Color _hitColor = Color.white;
private float _stunTimer;
private float _flashTimer;
private bool _removed;
private Color _baseColor;
public EnemyDef Def { get; private set; }
public float Hp { get; private set; }
public bool IsAlive => !_removed && Hp > 0f;
public Vector2 Ground => Body.Ground;
protected GroundBody Body { get; private set; }
protected TruckController Truck { get; private set; }
protected virtual void Awake()
{
Body = GetComponent<GroundBody>();
if (_renderer != null) _baseColor = _renderer.color;
}
// OnEnable/OnDisable rodam quando o objeto aparece e quando é destruído:
// é o jeito seguro de manter a lista All sempre certa.
private void OnEnable() => All.Add(this);
private void OnDisable() => All.Remove(this);
// Chamado pelo EnemySpawner logo depois de criar o inimigo.
public virtual void Init(EnemyDef def, TruckController truck, Vector2 ground)
{
Def = def;
Truck = truck;
Hp = def.maxHp;
Body.Ground = ground;
Body.Apply();
}
private void Update()
{
if (Truck == null || !IsAlive) return;
float dt = Time.deltaTime;
_flashTimer -= dt;
if (_renderer != null) _renderer.color = _flashTimer > 0f ? _hitColor : _baseColor;
// Atordoado: fica parado no chão, então o caminhão se afasta dele.
if (_stunTimer > 0f) _stunTimer -= dt;
else Tick(dt);
if (Body.Ground.x < Truck.Distance - GameConfig.I.enemyDespawnBehind) Remove(false);
}
// O comportamento de cada tipo de inimigo, rodado uma vez por frame.
protected abstract void Tick(float dt);
// Anda em linha reta até um ponto do chão e devolve a distância que falta.
protected float MoveTowards(Vector2 target, float dt)
{
Body.Ground = Vector2.MoveTowards(Body.Ground, target, Def.speed * dt);
return Vector2.Distance(Body.Ground, target);
}
public void TakeDamage(float amount)
{
if (!IsAlive) return;
Hp -= amount;
_flashTimer = 0.06f;
if (Hp <= 0f) Remove(true);
}
// Atordoa (arpão, óleo). Só funciona em quem pode ser atordoado.
public void Stun(float seconds)
{
if (Def.stunnable) _stunTimer = Mathf.Max(_stunTimer, seconds);
}
// Some sem dar sucata (fim do trecho, ficou para trás, explodiu sozinho).
public void Despawn() => Remove(false);
// Tira o inimigo do jogo. Só quem foi morto pelas defesas avisa Killed.
protected void Remove(bool killedByPlayer)
{
if (_removed) return;
_removed = true;
if (killedByPlayer) Killed?.Invoke(this);
OnRemoved();
Destroy(gameObject);
}
// Gancho para os tipos que precisam limpar algo ao sair (saqueador, chefe).
protected virtual void OnRemoved() { }
// Procura o inimigo vivo mais perto de um slot, dentro do alcance e do
// leque de tiro do slot.
public static Enemy FindNearest(TruckSlot slot, float range, bool vehiclesOnly)
{
Enemy best = null;
float bestSqr = range * range;
Vector2 from = slot.Ground;
foreach (Enemy enemy in All)
{
if (!enemy.IsAlive || (vehiclesOnly && !enemy.Def.isVehicle)) continue;
float sqr = (enemy.Ground - from).sqrMagnitude;
if (sqr > bestSqr || !slot.CanTarget(enemy.Ground)) continue;
best = enemy;
bestSqr = sqr;
}
return best;
}
}
}
Destroynão apaga o objeto na hora: ele some no fim do frame. Por isso existe o_removed, que impede o mesmo inimigo de morrer duas vezes no mesmo frame (por exemplo, atingido por duas metralhadoras). Pelo mesmo motivo, as defesas podem percorrerEnemy.Allcomforeache matar inimigos no meio do laço: a lista só muda no fim do frame.
3.3 Motoqueiro: BikerEnemy
// Caminho: Assets/_Project/Scripts/Enemies/BikerEnemy.cs
using FuryHauler.Data;
using FuryHauler.Truck;
using UnityEngine;
namespace FuryHauler.Enemies
{
// POR QUE: o inimigo básico (Parte 1 §7): emparelha com o caminhão pela
// lateral e atira.
// ESTRATÉGIA: escolhe um "posto" ao lado do caminhão (do lado em que
// nasceu) e persegue esse posto. Como o posto anda junto com o caminhão e o
// motoqueiro é mais rápido, ele chega e fica grudado. Só atira estando
// perto do posto, então o nitro (Fase 6) o faz parar de atirar.
public class BikerEnemy : Enemy
{
[SerializeField] private float _sideDistance = 4f;
[SerializeField] private float _attackDistance = 1.5f;
private Vector2 _post;
private float _fireTimer;
public override void Init(EnemyDef def, TruckController truck, Vector2 ground)
{
base.Init(def, truck, ground);
float side = ground.y >= 0f ? 1f : -1f;
_post = new Vector2(Random.Range(-3f, 3f), side * _sideDistance);
_fireTimer = def.fireInterval;
}
protected override void Tick(float dt)
{
float remaining = MoveTowards(Truck.Ground + _post, dt);
if (remaining > _attackDistance) return;
_fireTimer -= dt;
if (_fireTimer > 0f) return;
_fireTimer = Def.fireInterval;
Truck.Health.TakeDamage(Def.damage);
}
}
}
3.4 Carro-bomba: RammerEnemy
// Caminho: Assets/_Project/Scripts/Enemies/RammerEnemy.cs
namespace FuryHauler.Enemies
{
// POR QUE: cria urgência (Parte 1 §7): se não for destruído a tempo, bate
// e tira muita vida de uma vez.
// ESTRATÉGIA: vai direto no centro do caminhão. Ao chegar perto o
// suficiente, causa o dano e some sem dar sucata (explodiu sozinho).
public class RammerEnemy : Enemy
{
protected override void Tick(float dt)
{
float remaining = MoveTowards(Truck.Ground, dt);
if (remaining > Def.contactRadius) return;
Truck.Health.TakeDamage(Def.damage);
Remove(false);
}
}
}
3.5 Buggy de abordagem: BoarderEnemy
// Caminho: Assets/_Project/Scripts/Enemies/BoarderEnemy.cs
using FuryHauler.Data;
using FuryHauler.Trip;
using FuryHauler.Truck;
using UnityEngine;
namespace FuryHauler.Enemies
{
// POR QUE: encosta e solta saqueadores que sobem no caminhão (Parte 1 §7).
// ESTRATÉGIA: encosta bem perto da lateral; parado ali, solta um saqueador
// a cada spawnInterval num slot sorteado. Quando acaba a "tripulação", ele
// desacelera e fica para trás até sumir.
public class BoarderEnemy : Enemy
{
[SerializeField] private float _sideDistance = 2.2f;
private Vector2 _post;
private float _timer;
private int _left;
public override void Init(EnemyDef def, TruckController truck, Vector2 ground)
{
base.Init(def, truck, ground);
float side = ground.y >= 0f ? 1f : -1f;
_post = new Vector2(Random.Range(-1.5f, 1.5f), side * _sideDistance);
_timer = def.spawnInterval;
_left = def.spawnCount;
}
protected override void Tick(float dt)
{
if (_left <= 0)
{
// Vai embora: anda na metade da velocidade do caminhão.
Body.Ground += new Vector2(Truck.Speed * 0.5f * dt, 0f);
return;
}
if (MoveTowards(Truck.Ground + _post, dt) > 1f) return;
_timer -= dt;
if (_timer > 0f) return;
_timer = Def.spawnInterval;
_left--;
DeployRaider();
}
// Cria um saqueador já em cima de um slot sorteado.
private void DeployRaider()
{
if (Def.spawnDef == null || Truck.Slots.Count == 0) return;
TruckSlot slot = Truck.Slots[Random.Range(0, Truck.Slots.Count)];
if (EnemySpawner.Current.Spawn(Def.spawnDef, slot.Ground) is RaiderEnemy raider)
raider.Board(slot);
}
}
}
3.6 Saqueador: RaiderEnemy
// Caminho: Assets/_Project/Scripts/Enemies/RaiderEnemy.cs
using FuryHauler.Truck;
namespace FuryHauler.Enemies
{
// POR QUE: o comportamento do saqueador está Em aberto (Parte 1 §7). O
// placeholder: ele sabota a defesa do slot onde subiu e machuca o caminhão
// aos poucos até ser morto.
// ESTRATÉGIA: fica "colado" na posição do slot todo frame. Avisa o slot ao
// subir e ao sair (morto ou fim do trecho), para a sabotagem nunca ficar
// presa. O dano é por segundo (Def.damage × dt).
public class RaiderEnemy : Enemy
{
private TruckSlot _slot;
// Sobe no slot. Chamado pelo buggy logo depois de criar o saqueador.
public void Board(TruckSlot slot)
{
_slot = slot;
_slot.AddSaboteur();
}
protected override void Tick(float dt)
{
if (_slot == null) return;
Body.Ground = _slot.Ground;
Truck.Health.TakeDamage(Def.damage * dt);
}
protected override void OnRemoved()
{
if (_slot != null) _slot.RemoveSaboteur();
_slot = null;
}
}
}
3.7 Chefe: BossEnemy
// Caminho: Assets/_Project/Scripts/Enemies/BossEnemy.cs
using FuryHauler.Data;
using FuryHauler.Trip;
using FuryHauler.Truck;
using UnityEngine;
namespace FuryHauler.Enemies
{
// POR QUE: o último trecho tem um chefe (Parte 1 §7). Placeholder: ele
// emparelha e atira como um motoqueiro muito resistente e chama reforços.
// ESTRATÉGIA: herda do BikerEnemy (reaproveita "emparelhar e atirar") e
// soma um timer de reforços. Enquanto existir, segura a chegada a B pelo
// contador do TripManager, se a regra bossBlocksArrival estiver ligada.
public class BossEnemy : BikerEnemy
{
private float _spawnTimer;
private bool _blocking;
public override void Init(EnemyDef def, TruckController truck, Vector2 ground)
{
base.Init(def, truck, ground);
_spawnTimer = def.spawnInterval;
if (GameConfig.I.bossBlocksArrival)
{
TripManager.Current.AddArrivalBlocker();
_blocking = true;
}
}
protected override void Tick(float dt)
{
base.Tick(dt);
if (Def.spawnDef == null) return;
_spawnTimer -= dt;
if (_spawnTimer > 0f) return;
_spawnTimer = Def.spawnInterval;
EnemySpawner.Current.SpawnBehind(Def.spawnDef);
}
protected override void OnRemoved()
{
if (_blocking) TripManager.Current.RemoveArrivalBlocker();
_blocking = false;
}
}
}
3.8 Quem cria os inimigos: EnemySpawner
// Caminho: Assets/_Project/Scripts/Trip/EnemySpawner.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Enemies;
using UnityEngine;
namespace FuryHauler.Trip
{
// POR QUE: a pressão de inimigos precisa crescer com a dificuldade do
// trecho (Parte 1 §8) sem ninguém escrever ondas à mão para cada trecho
// gerado.
// ESTRATÉGIA: "orçamento de spawn". A cada segundo entram alguns pontos
// (mais em trechos difíceis). O spawner sorteia o próximo inimigo entre os
// permitidos na dificuldade e espera juntar pontos suficientes para ele.
// Sortear antes de juntar evita que só os baratos apareçam. O chefe nasce
// uma vez, ao passar de certa porcentagem do trecho.
public class EnemySpawner : MonoBehaviour
{
public static EnemySpawner Current { get; private set; }
[SerializeField] private TripManager _trip;
[SerializeField] private List<EnemyDef> _pool = new(); // inimigos comuns
[SerializeField] private EnemyDef _boss;
private readonly List<EnemyDef> _eligible = new();
private float _budget;
private EnemyDef _next;
private bool _bossSpawned;
private bool _active;
private void Awake()
{
Current = this;
}
// Assina os eventos do trecho enquanto o spawner estiver ativo.
private void OnEnable()
{
_trip.Started += OnTripStarted;
_trip.Ended += OnTripEnded;
}
private void OnDisable()
{
_trip.Started -= OnTripStarted;
_trip.Ended -= OnTripEnded;
}
private void OnTripStarted(TripSettings settings)
{
ClearAll();
_budget = 0f;
_next = null;
_bossSpawned = false;
_active = true;
}
private void OnTripEnded(bool success)
{
_active = false;
ClearAll();
}
private void Update()
{
if (!_active) return;
GameConfig cfg = GameConfig.I;
TripSettings settings = _trip.Settings;
if (settings.hasBoss && !_bossSpawned && _boss != null && _trip.Progress01 >= cfg.bossSpawnAtProgress)
{
_bossSpawned = true;
SpawnBehind(_boss);
}
_budget += (cfg.spawnBudgetBase + cfg.spawnBudgetPerDifficulty * settings.difficulty) * Time.deltaTime;
if (_next == null) _next = PickNext(settings.difficulty);
if (_next != null && _budget >= _next.spawnCost)
{
_budget -= _next.spawnCost;
SpawnBehind(_next);
_next = null;
}
}
// Sorteia um inimigo comum permitido nesta dificuldade.
private EnemyDef PickNext(int difficulty)
{
_eligible.Clear();
foreach (EnemyDef def in _pool)
if (def.minDifficulty <= difficulty) _eligible.Add(def);
return _eligible.Count == 0 ? null : _eligible[Random.Range(0, _eligible.Count)];
}
// Cria um inimigo atrás do caminhão, num lado sorteado da estrada.
public Enemy SpawnBehind(EnemyDef def)
{
GameConfig cfg = GameConfig.I;
float side = Random.value < 0.5f ? -1f : 1f;
Vector2 offset = new Vector2(
-Random.Range(cfg.spawnBehindMin, cfg.spawnBehindMax),
side * Random.Range(0.5f, cfg.roadHalfWidth));
return Spawn(def, _trip.Truck.Ground + offset);
}
// Cria um inimigo num ponto exato do chão.
public Enemy Spawn(EnemyDef def, Vector2 ground)
{
Enemy enemy = Instantiate(def.prefab, transform);
enemy.Init(def, _trip.Truck, ground);
return enemy;
}
// Tira todos os inimigos do jogo, sem dar sucata.
public void ClearAll()
{
for (int i = Enemy.All.Count - 1; i >= 0; i--) Enemy.All[i].Despawn();
}
}
}
3.9 Criar prefabs e assets
- Prefabs (em
Prefabs/Enemies/), cada um: GameObject vazio + GroundBody (noSortingGroup, Sorting Layer = Mundo) + o script do tipo + um filho spriteCircleligado no campoRendererdo script.
| Prefab | Script | Sprite (escala, cor) | GroundBody |
|---|---|---|---|
Biker | BikerEnemy | 0.5, amarelo | — |
Rammer | RammerEnemy | 0.8, laranja | — |
Boarder | BoarderEnemy | 1.0, roxo | — |
Raider | RaiderEnemy | 0.35, vermelho | Height 0.6, Sort Bias 50 |
Boss | BossEnemy | 2.0, preto | — |
- Assets (em
ScriptableObjects/Enemies/, Create → Fury Hauler → Enemy), com os números da Parte 1 §15 (campos não listados ficam no padrão):
| Asset | Prefab | HP | Speed | Damage | Fire Int. | Contact | Vehicle | Stunnable | Spawn Def / Count / Interval | Scrap | Cost | Min Diff. |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
Biker | Biker | 30 | 16 | 4 | 1 | — | sim | sim | — | 8 | 1 | 0 |
Rammer | Rammer | 50 | 18 | 60 | — | 1.8 | sim | sim | — | 12 | 2 | 1 |
Boarder | Boarder | 80 | 14 | — | — | — | sim | sim | Raider / 2 / 2 | 15 | 3 | 2 |
Raider | Raider | 20 | — | 6 | — | — | não | não | — | 3 | — | — |
Boss | Boss | 1500 | 12 | 15 | 1.5 | — | sim | não | Biker / — / 6 | 150 | — | — |
- Spawner: GameObject
Enemiescom EnemySpawner:Trip=Trip,Pool= Biker, Rammer, Boarder,Boss= Boss.
✅ Checkpoint da Fase 3
- No Play (dificuldade de teste 2), motoqueiros aparecem atrás, emparelham dos lados e o caminhão pisca vermelho a cada tiro.
- Carros-bomba vêm direto, somem ao encostar e o caminhão pisca.
- Buggies encostam e soltam pontos vermelhos em cima dos slots; o marcador do slot fica vermelho (sabotado).
- Com
Has Bossligado nasTest SettingsdoTripManager, o chefe aparece em 30% do trecho e o caminhão não termina o trecho enquanto ele existir. - Sem defesas, o caminhão acaba destruído, o Console mostra "Caminhão destruído." e todos os inimigos somem.
Problemas comuns
NullReferenceExceptionnoEnemySpawner.OnEnable: o campoTripdo spawner está vazio.- Os inimigos nunca alcançam o caminhão: a
Speeddo asset precisa ser maior que atruckSpeeddoGameConfig(10). - Inimigos aparecem por cima do caminhão quando deveriam estar atrás (ou o contrário): o
SortingGroupdo prefab está numa Sorting Layer diferente da do caminhão. Caminhão e inimigos ficam ambos emMundo.
Próxima fase: Defesas, com as 4 defesas montadas nos slots e mirando sozinhas.
Fase 4 — Defesas
Objetivo desta fase: metralhadora, lança-chamas, arpão e blindagem montadas nos slots, cada uma atacando sozinha do seu jeito.
4.1 Dados de uma defesa: DefenseDef
// Caminho: Assets/_Project/Scripts/Data/DefenseDef.cs
using FuryHauler.Defenses;
using UnityEngine;
namespace FuryHauler.Data
{
// POR QUE: preços e números das defesas são placeholders (Parte 1 §15).
// ESTRATÉGIA: um asset por defesa, com todos os campos que qualquer defesa
// pode precisar; cada tipo usa só os seus (ex.: só o arpão usa stunSeconds).
[CreateAssetMenu(menuName = "Fury Hauler/Defense", fileName = "Defense")]
public class DefenseDef : ScriptableObject
{
public string displayName = "Metralhadora";
public Sprite icon;
public Defense prefab;
[Header("Economia")]
public int cost = 40;
public int upgradeCostPerLevel = 40; // custo para subir = isto × nível atual
public int maxLevel = 3;
[Header("Combate")]
public float range = 9f; // metros
public float damage = 6f; // por tiro, ou por segundo (lança-chamas, espinhos)
public float fireInterval = 0.15f;
public float bonusPerLevel = 0.35f; // +35% por nível acima do 1
public float stunSeconds; // arpão
[Range(0f, 1f)] public float damageReduction; // blindagem
[Header("Desbloqueio (placeholder, Parte 1 §9)")]
public int unlockAtBestTrips; // trechos concluídos na melhor run para liberar
}
}
4.2 A base de toda defesa: Defense
// Caminho: Assets/_Project/Scripts/Defenses/Defense.cs
using FuryHauler.Data;
using FuryHauler.Trip;
using FuryHauler.Truck;
using UnityEngine;
namespace FuryHauler.Defenses
{
// POR QUE: toda defesa fica num slot, tem nível, sabe quanto já custou
// (para a venda, Fase 5) e para de funcionar se um saqueador sabotar o slot.
// ESTRATÉGIA: classe abstract com o comum; cada tipo implementa só o Tick.
// Um LineRenderer opcional desenha o rastro do tiro por uma fração de
// segundo, já que os acertos são instantâneos (sem projéteis voando).
public abstract class Defense : MonoBehaviour
{
[SerializeField] private LineRenderer _line;
private float _lineTimer;
public DefenseDef Def { get; private set; }
public TruckSlot Slot { get; private set; }
public int Level { get; private set; } = 1;
public int TotalSpent { get; private set; }
public bool CanUpgrade => Level < Def.maxLevel;
public int UpgradeCost => Def.upgradeCostPerLevel * Level;
// Multiplicador dos números pelo nível: 1.0 no nível 1, 1.35 no 2, 1.7 no 3.
protected float LevelMultiplier => 1f + Def.bonusPerLevel * (Level - 1);
protected float Damage => Def.damage * LevelMultiplier;
// Chamado pelo slot logo depois de criar a defesa.
public void Install(DefenseDef def, TruckSlot slot)
{
Def = def;
Slot = slot;
Level = 1;
TotalSpent = def.cost;
if (_line != null) _line.enabled = false;
}
// Sobe um nível. Quem chama (a alocação) já cobrou o UpgradeCost.
public void Upgrade()
{
if (!CanUpgrade) return;
TotalSpent += UpgradeCost;
Level++;
}
private void Update()
{
UpdateLine(Time.deltaTime);
if (Slot == null) return;
bool running = TripManager.Current != null && TripManager.Current.IsRunning;
if (Slot.Sabotaged || !running)
{
OnIdle();
return;
}
Tick(Time.deltaTime);
}
// O comportamento da defesa, rodado uma vez por frame.
protected abstract void Tick(float dt);
// Chamado nos frames em que a defesa não pode agir (sabotada, trecho parado).
protected virtual void OnIdle() { }
// Mostra um rastro da defesa até um ponto da tela por alguns segundos.
protected void ShowLine(Vector3 to, float seconds)
{
if (_line == null) return;
_line.positionCount = 2;
_line.SetPosition(0, transform.position);
_line.SetPosition(1, to);
_line.enabled = true;
_lineTimer = seconds;
}
// Apaga o rastro quando o tempo dele acaba.
private void UpdateLine(float dt)
{
if (_line == null || !_line.enabled) return;
_lineTimer -= dt;
if (_lineTimer <= 0f) _line.enabled = false;
}
}
}
4.3 Metralhadora: MachineGunDefense
// Caminho: Assets/_Project/Scripts/Defenses/MachineGunDefense.cs
using FuryHauler.Enemies;
namespace FuryHauler.Defenses
{
// POR QUE: a defesa padrão (Parte 1 §4): tiro rápido, um alvo por vez.
// ESTRATÉGIA: a cada fireInterval, acerta o inimigo mais próximo dentro do
// alcance e do leque do slot. Saqueadores contam como alvo.
public class MachineGunDefense : Defense
{
private float _cooldown;
protected override void Tick(float dt)
{
_cooldown -= dt;
if (_cooldown > 0f) return;
Enemy target = Enemy.FindNearest(Slot, Def.range, vehiclesOnly: false);
if (target == null) return;
_cooldown = Def.fireInterval;
target.TakeDamage(Damage);
ShowLine(target.transform.position, 0.05f);
}
}
}
4.4 Lança-chamas: FlamethrowerDefense
// Caminho: Assets/_Project/Scripts/Defenses/FlamethrowerDefense.cs
using FuryHauler.Enemies;
using FuryHauler.World;
using UnityEngine;
namespace FuryHauler.Defenses
{
// POR QUE: dano em área de curto alcance (Parte 1 §4), bom contra buggies
// encostados e saqueadores.
// ESTRATÉGIA: mira no inimigo mais próximo e queima todos que estiverem
// num cone apontado para ele, com dano por segundo (Damage × dt). O efeito
// visual é um ParticleSystem girado para a direção do cone na tela.
public class FlamethrowerDefense : Defense
{
[SerializeField] private ParticleSystem _flame;
[SerializeField] private float _coneDegrees = 60f;
protected override void Tick(float dt)
{
Enemy target = Enemy.FindNearest(Slot, Def.range, vehiclesOnly: false);
if (target == null)
{
SetFlame(false, Vector2.zero);
return;
}
Vector2 aim = target.Ground - Slot.Ground;
if (aim.sqrMagnitude < 0.01f) aim = IsoProjection.Dir(Slot.FacingDegrees);
aim.Normalize();
float rangeSqr = Def.range * Def.range;
foreach (Enemy enemy in Enemy.All)
{
if (!enemy.IsAlive) continue;
Vector2 toEnemy = enemy.Ground - Slot.Ground;
if (toEnemy.sqrMagnitude > rangeSqr) continue;
if (toEnemy.sqrMagnitude > 0.01f && Vector2.Angle(aim, toEnemy) > _coneDegrees * 0.5f) continue;
enemy.TakeDamage(Damage * dt);
}
SetFlame(true, aim);
}
protected override void OnIdle() => SetFlame(false, Vector2.zero);
// Liga/desliga as partículas e gira para a direção do cone na tela.
private void SetFlame(bool on, Vector2 groundAim)
{
if (_flame == null) return;
if (!on)
{
if (_flame.isPlaying) _flame.Stop();
return;
}
Vector3 screenAim = IsoProjection.ToScreen(groundAim);
float angle = Mathf.Atan2(screenAim.y, screenAim.x) * Mathf.Rad2Deg;
_flame.transform.rotation = Quaternion.Euler(0f, 0f, angle);
if (!_flame.isPlaying) _flame.Play();
}
}
}
4.5 Arpão: HarpoonDefense
// Caminho: Assets/_Project/Scripts/Defenses/HarpoonDefense.cs
using FuryHauler.Enemies;
namespace FuryHauler.Defenses
{
// POR QUE: "derruba" um veículo (Parte 1 §4): ele para de acompanhar e
// fica para trás. O efeito exato de "puxar" ainda está Em aberto.
// ESTRATÉGIA: tiro lento e forte, só em veículos, que causa dano e
// atordoa. Como o inimigo atordoado fica parado no chão e o caminhão
// segue, ele cai para trás sozinho e pode até sair do jogo.
public class HarpoonDefense : Defense
{
private float _cooldown;
protected override void Tick(float dt)
{
_cooldown -= dt;
if (_cooldown > 0f) return;
Enemy target = Enemy.FindNearest(Slot, Def.range, vehiclesOnly: true);
if (target == null) return;
_cooldown = Def.fireInterval;
target.TakeDamage(Damage);
target.Stun(Def.stunSeconds * LevelMultiplier);
ShowLine(target.transform.position, 0.2f);
}
}
}
4.6 Blindagem com espinhos: ArmorDefense
// Caminho: Assets/_Project/Scripts/Defenses/ArmorDefense.cs
using FuryHauler.Enemies;
namespace FuryHauler.Defenses
{
// POR QUE: defesa passiva (Parte 1 §4): reduz o dano do caminhão e fere
// quem chega perto do slot, inclusive saqueadores a bordo.
// ESTRATÉGIA: a redução não é aplicada aqui: o TruckHealth soma a
// Reduction de todas as blindagens na hora de calcular o dano (assim não
// há "registro" para esquecer de desfazer na venda). Os espinhos causam
// dano por segundo a todos dentro do alcance (curto) do slot.
public class ArmorDefense : Defense
{
public float Reduction => Def.damageReduction * LevelMultiplier;
protected override void Tick(float dt)
{
float rangeSqr = Def.range * Def.range;
foreach (Enemy enemy in Enemy.All)
{
if (!enemy.IsAlive) continue;
if ((enemy.Ground - Slot.Ground).sqrMagnitude <= rangeSqr)
enemy.TakeDamage(Damage * dt);
}
}
}
}
4.7 TruckSlot (versão final)
Substitua o arquivo inteiro da Fase 2 por este. As novidades: guardar a defesa montada, montar/desmontar, uma defesa de teste e os destaques visuais que a alocação (Fase 5) vai usar.
// Caminho: Assets/_Project/Scripts/Truck/TruckSlot.cs
using FuryHauler.Data;
using FuryHauler.Defenses;
using FuryHauler.World;
using UnityEngine;
namespace FuryHauler.Truck
{
// POR QUE: a decisão central do jogador é "qual defesa em qual slot"
// (Parte 1 §3). Cada slot tem uma posição no caminhão, um ângulo de tiro
// e no máximo uma defesa montada.
// ESTRATÉGIA: o slot guarda um deslocamento no chão em relação ao centro do
// caminhão, então sua posição real é sempre "caminhão + deslocamento". O
// ângulo de tiro é um leque: direção central + abertura (360 = qualquer
// lado). O marcador colorido mostra o estado: vazio, destacado para compra,
// selecionado ou sabotado. DefaultExecutionOrder(50) faz o slot se
// posicionar depois do GroundBody do caminhão.
[DefaultExecutionOrder(50)]
public class TruckSlot : MonoBehaviour
{
[SerializeField] private string _label = "Teto";
[SerializeField] private Vector2 _offset; // metros a partir do centro do caminhão
[SerializeField] private float _height = 0.6f; // altura na tela (em cima do caminhão)
[SerializeField] private float _facingDegrees; // 0 frente, 90 esquerda, -90 direita, 180 trás
[SerializeField, Range(10f, 360f)] private float _arcDegrees = 360f;
[SerializeField] private SpriteRenderer _marker;
[SerializeField] private Color _idleColor = new Color(1f, 1f, 1f, 0.35f);
[SerializeField] private Color _highlightColor = new Color(0.3f, 1f, 0.3f, 0.9f);
[SerializeField] private Color _selectedColor = Color.yellow;
[SerializeField] private Color _sabotagedColor = Color.red;
[SerializeField] private DefenseDef _testDefense; // só para teste; deixe vazio na Fase 7
private int _saboteurs;
public TruckController Truck { get; private set; }
public Defense Installed { get; private set; }
public bool IsEmpty => Installed == null;
public string Label => _label;
public float FacingDegrees => _facingDegrees;
public float ArcDegrees => _arcDegrees;
public bool Sabotaged => _saboteurs > 0;
public bool Highlighted { get; set; } // slot vazio onde dá para montar a defesa escolhida
public bool Selected { get; set; } // slot aberto no painel de defesa
public Vector2 Ground => Truck.Body.Ground + _offset;
// Chamado pelo caminhão no Awake dele: o slot passa a saber a quem pertence.
public void Bind(TruckController truck)
{
Truck = truck;
}
private void Start()
{
if (_testDefense != null) Install(_testDefense);
}
// Diz se um ponto no chão está dentro do leque de tiro deste slot.
public bool CanTarget(Vector2 point)
{
if (_arcDegrees >= 360f) return true;
Vector2 toPoint = point - Ground;
float angle = Vector2.SignedAngle(IsoProjection.Dir(_facingDegrees), toPoint);
return Mathf.Abs(angle) <= _arcDegrees * 0.5f;
}
// Cria a defesa como filha do slot. Não cobra nada: quem cobra é a alocação.
public Defense Install(DefenseDef def)
{
if (!IsEmpty || def == null || def.prefab == null) return null;
Installed = Instantiate(def.prefab, transform);
Installed.transform.localPosition = Vector3.zero;
Installed.Install(def, this);
return Installed;
}
// Desmonta e destrói a defesa do slot.
public void Clear()
{
if (IsEmpty) return;
Destroy(Installed.gameObject);
Installed = null;
}
// Saqueadores (Fase 3) avisam quando sobem e quando saem do slot.
public void AddSaboteur() => _saboteurs++;
public void RemoveSaboteur() => _saboteurs = Mathf.Max(0, _saboteurs - 1);
private void LateUpdate()
{
if (Truck == null) return;
transform.position = IsoProjection.ToScreen(Ground, _height);
UpdateMarker();
}
// Escolhe a cor do marcador, do estado mais importante para o menos.
private void UpdateMarker()
{
if (_marker == null) return;
if (Sabotaged) _marker.color = _sabotagedColor;
else if (Selected) _marker.color = _selectedColor;
else if (Highlighted) _marker.color = _highlightColor;
else _marker.color = _idleColor;
// Slot ocupado só mostra o marcador quando tem algo a dizer.
_marker.enabled = IsEmpty || Selected || Sabotaged;
}
}
}
4.8 Blindagem no cálculo de dano
No TruckHealth da Fase 2, adicione using FuryHauler.Defenses; no topo e troque a linha da DamageReduction por:
// Quanto do dano é ignorado (0 a 1): soma das blindagens que não estão
// sabotadas, com um teto (GameConfig.maxDamageReduction) para o
// caminhão nunca ficar imortal.
public float DamageReduction
{
get
{
float sum = 0f;
foreach (TruckSlot slot in _truck.Slots)
if (slot.Installed is ArmorDefense armor && !slot.Sabotaged)
sum += armor.Reduction;
return Mathf.Min(sum, GameConfig.I.maxDamageReduction);
}
}
4.9 Criar prefabs e assets
- Material das linhas e partículas: no URP 2D, use o material
Sprite-Unlit-Default(procure por esse nome no Project, com o filtro In Packages). - Prefabs (em
Prefabs/Defenses/), cada um com um filho spriteSquare(escala0.35, Order in Layer = 10) para aparecer em cima do caminhão:-
MachineGun: MachineGunDefense + LineRenderer (Width0.05, cor amarela, Use World Space ligado, material acima, Sorting LayerFX) ligado emLine. -
Flamethrower: FlamethrowerDefense + filhoFlamecom Particle System:- Looping ligado, Play On Awake desligado, Start Lifetime
0.3, Start Speed12, Start Size0.3, cor laranja; - módulo Shape com Shape
Cone, Angle20e Rotation(0, 90, 0), para emitir para a direita (o "zero grau" do script); - módulo Renderer com o material acima e Sorting Layer
FX.
Ligue
Flameno campoFlame. - Looping ligado, Play On Awake desligado, Start Lifetime
-
Harpoon: HarpoonDefense + LineRenderer (Width0.08, cor cinza-metal), como na metralhadora. -
Armor: ArmorDefense, sem linha.
-
- Assets (em
ScriptableObjects/Defenses/, Create → Fury Hauler → Defense), números da Parte 1 §15:
| Asset | Prefab | Cost / Upgrade | Range | Damage | Fire Int. | Stun | Reduction | Unlock At Best Trips |
|---|---|---|---|---|---|---|---|---|
MachineGun | MachineGun | 40 / 40 | 9 | 6 | 0.15 | — | — | 0 |
Flamethrower | Flamethrower | 60 / 60 | 4.5 | 35 | — | — | — | 1 |
Harpoon | Harpoon | 70 / 70 | 12 | 40 | 3 | 2.5 | — | 3 |
Armor | Armor | 50 / 50 | 2.5 | 15 | — | — | 0.1 | 0 |
- Teste: nos slots do caminhão, preencha
Test Defense(ex.: metralhadora no teto, lança-chamas nas laterais de trás, arpão na traseira, blindagem numa lateral da frente).
✅ Checkpoint da Fase 4
- No Play, a metralhadora desenha rastros amarelos curtos e motoqueiros caem.
- Uma metralhadora num slot lateral esquerdo não atira em quem está à direita (leque de 180°).
- O lança-chamas solta fogo na direção do inimigo próximo e queima vários de uma vez.
- O arpão acerta um veículo, que para e fica para trás.
- Um saqueador no slot da metralhadora faz ela parar de atirar até ser morto (por outra defesa ou pelos espinhos de uma blindagem vizinha).
- Com as 6 defesas de teste, o caminhão termina o trecho de dificuldade 2 com vida sobrando; sem nenhuma, ele é destruído.
Problemas comuns
- Rastros ou fogo aparecem rosa: o material não é compatível com o URP 2D. Troque para
Sprite-Unlit-Default. - O fogo sai para o lado errado: a rotação do módulo Shape não está em
(0, 90, 0), então o "zero grau" das partículas não é a direita. - A defesa não aparece em cima do caminhão: o sprite dela está com Order in Layer menor que o do corpo do caminhão; dentro do mesmo
SortingGroupvale a ordem.
Próxima fase: Sucata e Alocação, para o jogador comprar, melhorar e vender defesas com o mouse.
Fase 5 — Sucata e Alocação
Objetivo desta fase: inimigos mortos dão sucata, e o jogador usa o mouse para comprar defesas na barra de baixo e montá-las num slot, além de melhorar e vender as montadas. Com o HUD mostrando sucata, vida e progresso.
5.1 A carteira: ScrapWallet
// Caminho: Assets/_Project/Scripts/Economy/ScrapWallet.cs
using System;
using FuryHauler.Data;
using FuryHauler.Enemies;
using FuryHauler.Trip;
using UnityEngine;
namespace FuryHauler.Economy
{
// POR QUE: a sucata é a única moeda da run (Parte 1 §5): entra quando um
// inimigo morre e sai quando o jogador compra ou melhora.
// ESTRATÉGIA: escuta o evento estático Enemy.Killed e soma a recompensa
// do inimigo, multiplicada pelo bônus de risco do trecho. A coleta é
// automática (placeholder: a Parte 1 §5 deixa Em aberto se o jogador
// precisa clicar na sucata). Um evento Changed avisa a UI.
public class ScrapWallet : MonoBehaviour
{
public static ScrapWallet Instance { get; private set; }
public int Amount { get; private set; }
public event Action<int> Changed;
private void Awake()
{
Instance = this;
Amount = GameConfig.I.startingScrap;
}
private void OnEnable() => Enemy.Killed += OnEnemyKilled;
private void OnDisable() => Enemy.Killed -= OnEnemyKilled;
private void OnEnemyKilled(Enemy enemy)
{
float multiplier = TripManager.Current != null ? TripManager.Current.Settings.scrapMultiplier : 1f;
if (multiplier <= 0f) multiplier = 1f; // TripSettings sem multiplicador preenchido
Add(Mathf.RoundToInt(enemy.Def.scrapReward * multiplier));
}
public void Add(int value)
{
if (value <= 0) return;
Amount += value;
Changed?.Invoke(Amount);
}
// Tenta gastar; devolve false (e não gasta nada) se não houver o bastante.
public bool TrySpend(int value)
{
if (value > Amount) return false;
Amount -= value;
Changed?.Invoke(Amount);
return true;
}
// Define o valor direto (começo de run, Fase 7).
public void ResetTo(int value)
{
Amount = value;
Changed?.Invoke(Amount);
}
}
}
5.2 Comprar, melhorar e vender: AllocationController
// Caminho: Assets/_Project/Scripts/Economy/AllocationController.cs
using System;
using FuryHauler.Data;
using FuryHauler.Defenses;
using FuryHauler.Truck;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
namespace FuryHauler.Economy
{
// POR QUE: alocar defesas é tudo que o jogador faz com o mouse (Parte 1
// §2): ele não dirige nem mira.
// ESTRATÉGIA: "clique-clique". Clicar numa defesa da barra de compra a
// deixa "pendente" e acende os slots vazios; clicar num slot vazio monta e
// cobra. Clicar num slot ocupado o seleciona e abre o painel de melhorar e
// vender. Clique fora, botão direito ou Esc cancelam. Os slots são achados
// pela distância na tela até o marcador, sem colliders. Arrastar e soltar
// fica para depois (Parte 1 §5 Em aberto).
public class AllocationController : MonoBehaviour
{
[SerializeField] private Camera _camera;
[SerializeField] private TruckController _truck;
[SerializeField] private float _pickRadius = 0.6f; // unidades de tela em volta do slot
public DefenseDef PendingBuy { get; private set; }
public TruckSlot SelectedSlot { get; private set; }
public event Action Changed; // seleção mudou (barra e painel redesenham)
public event Action<string> Message; // aviso curto para o HUD
// Chamado pelos botões da barra de compra. Clicar de novo na mesma cancela.
public void SelectToBuy(DefenseDef def)
{
PendingBuy = PendingBuy == def ? null : def;
SelectSlot(null);
RefreshHighlights();
Changed?.Invoke();
}
private void Update()
{
if (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame) CancelAll();
if (Mouse.current != null && Mouse.current.rightButton.wasPressedThisFrame) CancelAll();
Pointer pointer = Pointer.current;
if (pointer == null || !pointer.press.wasPressedThisFrame) return;
// Clique em cima de um botão da UI não é clique no mundo.
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return;
Vector2 world = _camera.ScreenToWorldPoint(pointer.position.ReadValue());
TruckSlot slot = FindSlot(world);
if (slot == null) CancelAll();
else if (PendingBuy != null && slot.IsEmpty) TryBuy(slot);
else if (!slot.IsEmpty) SelectSlot(slot);
}
// Slot mais perto do clique, dentro do raio, ou null.
private TruckSlot FindSlot(Vector2 world)
{
TruckSlot best = null;
float bestSqr = _pickRadius * _pickRadius;
foreach (TruckSlot slot in _truck.Slots)
{
float sqr = ((Vector2)slot.transform.position - world).sqrMagnitude;
if (sqr >= bestSqr) continue;
best = slot;
bestSqr = sqr;
}
return best;
}
// Cobra e monta a defesa pendente num slot vazio.
private void TryBuy(TruckSlot slot)
{
if (!ScrapWallet.Instance.TrySpend(PendingBuy.cost))
{
Message?.Invoke("Sucata insuficiente");
return;
}
slot.Install(PendingBuy);
PendingBuy = null;
RefreshHighlights();
Changed?.Invoke();
}
// Chamado pelo botão "Melhorar" do painel.
public void UpgradeSelected()
{
Defense defense = SelectedSlot != null ? SelectedSlot.Installed : null;
if (defense == null || !defense.CanUpgrade) return;
if (!ScrapWallet.Instance.TrySpend(defense.UpgradeCost))
{
Message?.Invoke("Sucata insuficiente");
return;
}
defense.Upgrade();
Changed?.Invoke();
}
// Quanto a venda devolve (placeholder: 50% de tudo que foi gasto).
public int SellValue(Defense defense)
{
return Mathf.RoundToInt(defense.TotalSpent * GameConfig.I.sellRefund);
}
// Chamado pelo botão "Vender" do painel. Vender e comprar outra é o "trocar" da Parte 1 §5.
public void SellSelected()
{
Defense defense = SelectedSlot != null ? SelectedSlot.Installed : null;
if (defense == null) return;
ScrapWallet.Instance.Add(SellValue(defense));
SelectedSlot.Clear();
SelectSlot(null);
Changed?.Invoke();
}
// Desfaz compra pendente e seleção.
public void CancelAll()
{
PendingBuy = null;
SelectSlot(null);
RefreshHighlights();
Changed?.Invoke();
}
private void SelectSlot(TruckSlot slot)
{
if (SelectedSlot != null) SelectedSlot.Selected = false;
SelectedSlot = slot;
if (SelectedSlot != null) SelectedSlot.Selected = true;
Changed?.Invoke();
}
// Acende os slots vazios enquanto houver uma compra pendente.
private void RefreshHighlights()
{
foreach (TruckSlot slot in _truck.Slots)
slot.Highlighted = PendingBuy != null && slot.IsEmpty;
}
}
}
5.3 Um botão da barra de compra: ShopButton
// Caminho: Assets/_Project/Scripts/UI/ShopButton.cs
using System;
using FuryHauler.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace FuryHauler.UI
{
// POR QUE: cada defesa liberada vira um botão com nome e preço.
// ESTRATÉGIA: o botão não sabe nada de compra; recebe uma ação para
// chamar no clique (quem cria decide o que acontece). Fica desabilitado
// quando falta sucata e mostra uma moldura quando está escolhido.
public class ShopButton : MonoBehaviour
{
[SerializeField] private Button _button;
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _label;
[SerializeField] private Image _selectedFrame;
public DefenseDef Def { get; private set; }
public void Setup(DefenseDef def, Action onClick)
{
Def = def;
_icon.sprite = def.icon;
_icon.enabled = def.icon != null;
_label.text = $"{def.displayName}\n{def.cost}";
_button.onClick.AddListener(() => onClick());
}
public void Refresh(int scrap, bool selected)
{
_button.interactable = scrap >= Def.cost;
_selectedFrame.enabled = selected;
}
}
}
5.4 A barra de compra: ShopBar
// Caminho: Assets/_Project/Scripts/UI/ShopBar.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Economy;
using UnityEngine;
namespace FuryHauler.UI
{
// POR QUE: mostra as defesas que podem ser compradas agora.
// ESTRATÉGIA: cria um ShopButton por defesa dentro de um container com
// HorizontalLayoutGroup (a Unity alinha os botões sozinha). A lista começa
// com todas as defesas do Inspector; na Fase 7 a run troca por só as
// desbloqueadas, chamando SetDefenses.
public class ShopBar : MonoBehaviour
{
[SerializeField] private AllocationController _allocation;
[SerializeField] private ShopButton _buttonPrefab;
[SerializeField] private Transform _container;
[SerializeField] private List<DefenseDef> _defenses = new();
private readonly List<ShopButton> _buttons = new();
private void Start()
{
ScrapWallet.Instance.Changed += _ => Refresh();
_allocation.Changed += Refresh;
Rebuild();
}
// Troca as defesas mostradas (desbloqueios, Fase 7).
public void SetDefenses(IEnumerable<DefenseDef> defenses)
{
_defenses = new List<DefenseDef>(defenses);
Rebuild();
}
// Apaga e recria todos os botões.
private void Rebuild()
{
foreach (ShopButton button in _buttons) Destroy(button.gameObject);
_buttons.Clear();
foreach (DefenseDef def in _defenses)
{
ShopButton button = Instantiate(_buttonPrefab, _container);
DefenseDef captured = def; // cópia para o "=>" abaixo lembrar desta defesa
button.Setup(def, () => _allocation.SelectToBuy(captured));
_buttons.Add(button);
}
Refresh();
}
private void Refresh()
{
int scrap = ScrapWallet.Instance.Amount;
foreach (ShopButton button in _buttons)
button.Refresh(scrap, button.Def == _allocation.PendingBuy);
}
}
}
5.5 O painel da defesa selecionada: DefensePanel
// Caminho: Assets/_Project/Scripts/UI/DefensePanel.cs
using FuryHauler.Defenses;
using FuryHauler.Economy;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace FuryHauler.UI
{
// POR QUE: melhorar e vender precisam de um lugar na tela (Parte 1 §12).
// ESTRATÉGIA: o componente fica sempre ativo e liga/desliga só um filho
// (_root). Assim ele continua escutando os eventos mesmo escondido, e se
// redesenha sozinho quando a seleção ou a sucata mudam.
public class DefensePanel : MonoBehaviour
{
[SerializeField] private AllocationController _allocation;
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text _title;
[SerializeField] private TMP_Text _info;
[SerializeField] private Button _upgradeButton;
[SerializeField] private TMP_Text _upgradeLabel;
[SerializeField] private Button _sellButton;
[SerializeField] private TMP_Text _sellLabel;
private void Start()
{
_upgradeButton.onClick.AddListener(_allocation.UpgradeSelected);
_sellButton.onClick.AddListener(_allocation.SellSelected);
_allocation.Changed += Refresh;
ScrapWallet.Instance.Changed += _ => Refresh();
Refresh();
}
private void Refresh()
{
Defense defense = _allocation.SelectedSlot != null ? _allocation.SelectedSlot.Installed : null;
_root.SetActive(defense != null);
if (defense == null) return;
_title.text = $"{defense.Def.displayName} (nível {defense.Level}/{defense.Def.maxLevel})";
_info.text = $"Slot: {defense.Slot.Label}" + (defense.Slot.Sabotaged ? " [SABOTADO]" : "");
if (defense.CanUpgrade)
{
_upgradeLabel.text = $"Melhorar ({defense.UpgradeCost})";
_upgradeButton.interactable = ScrapWallet.Instance.Amount >= defense.UpgradeCost;
}
else
{
_upgradeLabel.text = "Nível máximo";
_upgradeButton.interactable = false;
}
_sellLabel.text = $"Vender (+{_allocation.SellValue(defense)})";
}
}
}
5.6 O HUD: HudView
// Caminho: Assets/_Project/Scripts/UI/HudView.cs
using FuryHauler.Economy;
using FuryHauler.Trip;
using FuryHauler.Truck;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace FuryHauler.UI
{
// POR QUE: sucata, vida e progresso precisam estar sempre à vista
// (Parte 1 §12).
// ESTRATÉGIA: sucata e nome do trecho mudam raramente, então são
// atualizados por evento; vida e progresso mudam todo frame e são lidos
// no Update. As barras são Images com "Image Type = Filled", em que
// fillAmount (0 a 1) controla quanto aparece.
public class HudView : MonoBehaviour
{
[SerializeField] private TripManager _trip;
[SerializeField] private TruckController _truck;
[SerializeField] private AllocationController _allocation;
[SerializeField] private TMP_Text _scrapText;
[SerializeField] private TMP_Text _tripLabel;
[SerializeField] private TMP_Text _messageText;
[SerializeField] private Image _hpFill;
[SerializeField] private Image _progressFill;
[SerializeField] private float _messageSeconds = 1.5f;
private float _messageTimer;
private void Start()
{
ScrapWallet.Instance.Changed += OnScrapChanged;
OnScrapChanged(ScrapWallet.Instance.Amount);
_allocation.Message += ShowMessage;
_trip.Started += settings => _tripLabel.text = settings.label;
_messageText.text = "";
}
private void OnScrapChanged(int amount)
{
_scrapText.text = $"Sucata: {amount}";
}
// Mostra um aviso curto que some sozinho.
private void ShowMessage(string text)
{
_messageText.text = text;
_messageTimer = _messageSeconds;
}
private void Update()
{
_hpFill.fillAmount = _truck.Health.Current / _truck.Health.Max;
_progressFill.fillAmount = _trip.Progress01;
if (_messageTimer <= 0f) return;
_messageTimer -= Time.deltaTime;
if (_messageTimer <= 0f) _messageText.text = "";
}
}
}
5.7 Montar na cena
- Lógica: GameObject
Economycom ScrapWallet e AllocationController (Camera= Main Camera,Truck= Truck). - Canvas: GameObject → UI → Canvas (já cria o
EventSystem). No Canvas: UI Scale Mode = Scale With Screen Size, referência1280 × 720. NoEventSystem, se aparecer o aviso, clique em Replace with InputSystemUIInputModule. - HUD (filhos do Canvas, com HudView num objeto
Hud):ScrapText(TextMeshPro) no canto superior esquerdo.TripLabel(TextMeshPro) no topo, centralizado.HpBar: uma Image de fundo escura e, dentro,HpFill, uma Image vermelha com Source Image =Square, Image Type = Filled, Fill Method = Horizontal.ProgressBar: igual, comProgressFillamarelo, logo abaixo do título.MessageText(TextMeshPro) no centro, um pouco acima da barra de compra.
- Barra de compra: objeto
ShopBarancorado embaixo, com HorizontalLayoutGroup (Spacing8, Child AlignmentMiddle Center) e o componente ShopBar (Container= ele mesmo,Defenses= os 4 assets). - Prefab
ShopButton(emPrefabs/UI/): um Button de120 × 80com filhosIcon(Image),Label(TextMeshPro) eSelectedFrame(Image com só a borda, amarela). Ligue os campos do ShopButton. - Painel: objeto
DefensePanel(sempre ativo) com o componente DefensePanel e um filhoRoot(o painel visível, no canto inferior direito) contendoTitle,Infoe os botõesUpgradeeSell, cada um com seu texto. - Apague os
Test Defensedos slots, para testar a compra de verdade.
✅ Checkpoint da Fase 5
- O HUD começa com "Sucata: 100", e a sucata sobe quando um inimigo morre por uma defesa (não quando um carro-bomba explode no caminhão).
- Clicar em "Metralhadora" acende os slots vazios em verde; clicar num deles monta a arma e desconta 40.
- Botões caros demais ficam cinza; tentar comprar sem sucata mostra "Sucata insuficiente".
- Clicar numa defesa montada abre o painel; "Melhorar" sobe o nível e cobra; "Vender" remove a defesa e devolve metade do gasto.
- Esc ou botão direito cancela a compra pendente e fecha o painel.
- As barras de vida e de progresso andam durante o trecho.
Problemas comuns
- Clicar num botão da barra também monta a defesa no slot embaixo dele: falta o
EventSystemna cena, entãoIsPointerOverGameObjectsempre devolve false. - Nenhum clique no mundo funciona: o
Pick Radiusé pequeno demais para o zoom da câmera, ou aCameradoAllocationControllerestá vazia. - As barras não mudam: a Image está com Image Type = Simple. O
fillAmountsó funciona com Filled, e só se houver uma Source Image.
Próxima fase: Habilidades Ativas, com nitro e mancha de óleo.
Fase 6 — Habilidades Ativas
Objetivo desta fase: dois botões de habilidade (placeholders: nitro e mancha de óleo), com cooldown visível e atalhos
1e2.
6.1 Dados de uma habilidade: AbilityDef
// Caminho: Assets/_Project/Scripts/Data/AbilityDef.cs
using FuryHauler.Abilities;
using UnityEngine;
namespace FuryHauler.Data
{
public enum AbilityKind
{
Nitro, // acelera o caminhão
OilSlick, // mancha de óleo atrás do caminhão
}
// POR QUE: a lista de habilidades está Em aberto (Parte 1 §6); nitro e
// óleo são placeholders. Um asset por habilidade deixa trocar números e
// ícones sem código.
// ESTRATÉGIA: um enum diz qual efeito aplicar, e os campos genéricos
// (duração, intensidade, tamanho) são lidos por cada efeito do seu jeito.
// Uma habilidade nova com efeito novo = um valor novo no enum + um case
// no AbilityController.
[CreateAssetMenu(menuName = "Fury Hauler/Ability", fileName = "Ability")]
public class AbilityDef : ScriptableObject
{
public string displayName = "Nitro";
public Sprite icon;
public AbilityKind kind;
public float cooldown = 20f;
public float duration = 4f; // nitro: tempo acelerando; óleo: tempo que a mancha fica na estrada
public float magnitude = 1.6f; // nitro: multiplicador de velocidade; óleo: segundos de atordoamento
public float size = 4f; // óleo: meio comprimento da mancha, em metros
public float dropBehind = 5f; // óleo: quantos metros atrás do caminhão a mancha cai
public OilSlick oilPrefab;
}
}
6.2 A mancha de óleo: OilSlick
// Caminho: Assets/_Project/Scripts/Abilities/OilSlick.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Enemies;
using FuryHauler.World;
using UnityEngine;
namespace FuryHauler.Abilities
{
// POR QUE: placeholder da Parte 1 §6: veículos que passam pelo óleo rodam.
// ESTRATÉGIA: a mancha fica parada no chão (o caminhão segue e a deixa para
// trás, bem no caminho de quem persegue). Ela cobre a estrada inteira na
// largura e "size" metros para cada lado no comprimento. Cada veículo só
// é atordoado uma vez por mancha (HashSet guarda quem já passou).
[RequireComponent(typeof(GroundBody))]
public class OilSlick : MonoBehaviour
{
[SerializeField] private SpriteRenderer _renderer;
private readonly HashSet<Enemy> _hit = new();
private GroundBody _body;
private AbilityDef _def;
private float _life;
// Chamado pelo AbilityController logo depois de criar a mancha.
public void Init(AbilityDef def, Vector2 ground)
{
_def = def;
_life = def.duration;
_body = GetComponent<GroundBody>();
_body.Ground = ground;
_body.Apply();
if (_renderer != null)
{
// Losango placeholder esticado para a largura da estrada.
float width = GameConfig.I.roadHalfWidth * 2f;
Vector3 size = _renderer.sprite.bounds.size;
_renderer.transform.localScale = new Vector3(
width * IsoProjection.HalfWidth * 2f / size.x,
width * IsoProjection.HalfHeight * 2f / size.y, 1f);
}
}
private void Update()
{
_life -= Time.deltaTime;
if (_life <= 0f)
{
Destroy(gameObject);
return;
}
float halfWidth = GameConfig.I.roadHalfWidth;
foreach (Enemy enemy in Enemy.All)
{
if (!enemy.IsAlive || !enemy.Def.isVehicle || _hit.Contains(enemy)) continue;
Vector2 d = enemy.Ground - _body.Ground;
if (Mathf.Abs(d.x) > _def.size || Mathf.Abs(d.y) > halfWidth) continue;
enemy.Stun(_def.magnitude);
_hit.Add(enemy);
}
}
}
}
O sprite do óleo é um losango (um quadrado de chão), então ele aparece com a largura da estrada nos dois sentidos, mesmo que a área que atordoa tenha
2 × sizemetros de comprimento. Com a arte final, desenhe a mancha com as proporções certas.
6.3 Quem usa as habilidades: AbilityController
// Caminho: Assets/_Project/Scripts/Abilities/AbilityController.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Trip;
using FuryHauler.Truck;
using UnityEngine;
using UnityEngine.InputSystem;
namespace FuryHauler.Abilities
{
// POR QUE: além de alocar, o jogador tem habilidades com cooldown
// (Parte 1 §6). Este é o único lugar que sabe aplicar cada efeito.
// ESTRATÉGIA: um cooldown por habilidade, na mesma ordem da lista. As
// teclas 1, 2, 3... usam a habilidade da posição correspondente. Tudo
// volta a zero no começo de cada trecho.
public class AbilityController : MonoBehaviour
{
[SerializeField] private TruckController _truck;
[SerializeField] private TripManager _trip;
[SerializeField] private List<AbilityDef> _abilities = new();
private float[] _cooldowns;
private float _nitroTimer;
public IReadOnlyList<AbilityDef> Abilities => _abilities;
private void Awake()
{
_cooldowns = new float[_abilities.Count];
}
private void OnEnable() => _trip.Started += OnTripStarted;
private void OnDisable() => _trip.Started -= OnTripStarted;
private void OnTripStarted(TripSettings settings)
{
for (int i = 0; i < _cooldowns.Length; i++) _cooldowns[i] = 0f;
_nitroTimer = 0f;
}
private void Update()
{
float dt = Time.deltaTime;
for (int i = 0; i < _cooldowns.Length; i++) _cooldowns[i] = Mathf.Max(0f, _cooldowns[i] - dt);
if (_nitroTimer > 0f)
{
_nitroTimer -= dt;
if (_nitroTimer <= 0f) _truck.SpeedMultiplier = 1f;
}
Keyboard keyboard = Keyboard.current;
if (keyboard == null) return;
for (int i = 0; i < _abilities.Count && i < 9; i++)
if (keyboard[Key.Digit1 + i].wasPressedThisFrame) TryUse(i);
}
// Fração do cooldown que ainda falta (1 = acabou de usar, 0 = pronta).
public float Cooldown01(int index)
{
float total = _abilities[index].cooldown;
return total <= 0f ? 0f : _cooldowns[index] / total;
}
// Usa a habilidade se o trecho estiver rolando e ela estiver pronta.
public bool TryUse(int index)
{
if (!_trip.IsRunning || _cooldowns[index] > 0f) return false;
AbilityDef ability = _abilities[index];
switch (ability.kind)
{
case AbilityKind.Nitro:
_truck.SpeedMultiplier = ability.magnitude;
_nitroTimer = ability.duration;
break;
case AbilityKind.OilSlick:
OilSlick oil = Instantiate(ability.oilPrefab);
oil.Init(ability, _truck.Ground + new Vector2(-ability.dropBehind, 0f));
break;
}
_cooldowns[index] = ability.cooldown;
return true;
}
}
}
6.4 Os botões: AbilityButton e AbilityBar
// Caminho: Assets/_Project/Scripts/UI/AbilityButton.cs
using System;
using FuryHauler.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace FuryHauler.UI
{
// POR QUE: o jogador precisa ver quando a habilidade volta.
// ESTRATÉGIA: uma Image escura por cima do botão, com "Fill Method =
// Radial 360", que vai se abrindo como um relógio conforme o cooldown acaba.
public class AbilityButton : MonoBehaviour
{
[SerializeField] private Button _button;
[SerializeField] private Image _icon;
[SerializeField] private Image _cooldownOverlay;
[SerializeField] private TMP_Text _label;
public void Setup(AbilityDef ability, int hotkey, Action onClick)
{
_icon.sprite = ability.icon;
_icon.enabled = ability.icon != null;
_label.text = $"[{hotkey}] {ability.displayName}";
_button.onClick.AddListener(() => onClick());
}
public void SetCooldown(float fraction)
{
_cooldownOverlay.fillAmount = fraction;
_button.interactable = fraction <= 0f;
}
}
}
// Caminho: Assets/_Project/Scripts/UI/AbilityBar.cs
using System.Collections.Generic;
using FuryHauler.Abilities;
using UnityEngine;
namespace FuryHauler.UI
{
// POR QUE: um botão por habilidade, criado a partir da lista do controller.
// ESTRATÉGIA: igual à barra de compra. O cooldown muda todo frame, então
// é lido no Update.
public class AbilityBar : MonoBehaviour
{
[SerializeField] private AbilityController _controller;
[SerializeField] private AbilityButton _buttonPrefab;
[SerializeField] private Transform _container;
private readonly List<AbilityButton> _buttons = new();
private void Start()
{
for (int i = 0; i < _controller.Abilities.Count; i++)
{
int index = i; // cópia para o "=>" abaixo lembrar deste índice
AbilityButton button = Instantiate(_buttonPrefab, _container);
button.Setup(_controller.Abilities[i], i + 1, () => _controller.TryUse(index));
_buttons.Add(button);
}
}
private void Update()
{
for (int i = 0; i < _buttons.Count; i++) _buttons[i].SetCooldown(_controller.Cooldown01(i));
}
}
}
6.5 Montar na cena
- Prefab
OilSlick(emPrefabs/): GameObject + GroundBody (SortingGroupna Sorting Layer Chao, Order10, para ficar sobre a estrada) + OilSlick + filho spriteDiamondpreto com alpha0.7ligado emRenderer. - Assets (em
ScriptableObjects/Abilities/, Create → Fury Hauler → Ability), números da Parte 1 §15:
| Asset | Kind | Cooldown | Duration | Magnitude | Size | Drop Behind | Oil Prefab |
|---|---|---|---|---|---|---|---|
Nitro | Nitro | 20 | 4 | 1.6 | — | — | — |
OilSlick | OilSlick | 15 | 5 | 1.5 | 4 | 5 | OilSlick |
- Controller: no objeto
Economy(ou num novoAbilities), adicione AbilityController comTruck,TripeAbilities= Nitro, OilSlick (nessa ordem: tecla 1 e tecla 2). - Prefab
AbilityButton(emPrefabs/UI/): Button100 × 100com filhosIcon,Labele, por último (para ficar por cima),CooldownOverlay: Image preta com alpha0.6, Source Image =Square, Image Type = Filled, Fill Method = Radial 360. - Barra: objeto
AbilityBarno canto inferior esquerdo do Canvas, com HorizontalLayoutGroup e o componente AbilityBar.
✅ Checkpoint da Fase 6
- Apertar
1(ou clicar no botão) acelera o caminhão por 4 s: motoqueiros ficam para trás e param de atirar, depois alcançam de novo. - Apertar
2deixa uma mancha escura atrás do caminhão; veículos que passam por ela param por 1,5 s. Saqueadores não são afetados. - Os botões escurecem e "abrem como relógio" até o fim do cooldown, e não funcionam antes disso.
- As habilidades não funcionam depois que o trecho acabou.
Problemas comuns
- A tecla
1do teclado numérico não funciona:Key.Digit1é a tecla1acima das letras. Se quiser as duas, cheque tambémKey.Numpad1 + i. - O caminhão fica acelerado para sempre: o trecho acabou durante o nitro e começou outro; o
OnTripStartedzera o timer, mas oTruckController.ResetForTripé quem volta oSpeedMultiplierpara 1. Confira se oResetForTripestá igual ao da Fase 2.
Próxima fase: Run Roguelite e Save, com mapa de rota, sequência de trechos, chefe no fim e desbloqueios permanentes.
Fase 7 — Run Roguelite e Save
Objetivo desta fase: o jogo completo de ponta a ponta. Menu → mapa de rota gerado → escolher trecho → jogar → voltar ao mapa → ... → chefe → chegada a B (ou derrota) → tela de resultado. E os desbloqueios permanentes salvos no navegador.
7.1 Um nó do mapa: RouteNode e RouteMap
// Caminho: Assets/_Project/Scripts/Run/RouteNode.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Trip;
namespace FuryHauler.Run
{
// POR QUE: cada trecho da rota é uma escolha do jogador (Parte 1 §8).
// ESTRATÉGIA: classe C# pura (não é MonoBehaviour, não vai na cena). O
// mapa é organizado em camadas: a camada 0 é o primeiro trecho, a última
// é o chefe. "next" guarda para quais nós da camada seguinte dá para ir.
// Os tipos de nó estão Em aberto; o placeholder só varia o risco.
public class RouteNode
{
public int layer;
public int index;
public int risk; // 0..maxRisk: mais inimigos e mais sucata
public bool isBoss;
public readonly List<int> next = new();
// Nome curto do nó, ex.: "2-B". O chefe se chama "B" (o destino).
public string Name => isBoss ? "B" : $"{layer + 1}-{(char)('A' + index)}";
// Converte o nó nas regras do trecho que o TripManager entende.
public TripSettings ToSettings(GameConfig cfg)
{
return new TripSettings
{
label = isBoss ? "Último trecho: chegada em B" : $"Trecho {Name} (risco {risk})",
difficulty = layer + 1 + risk,
hasBoss = isBoss,
scrapMultiplier = 1f + risk * cfg.scrapBonusPerRisk,
};
}
}
// O mapa inteiro: uma lista de camadas, cada uma com seus nós.
public class RouteMap
{
public readonly List<List<RouteNode>> layers = new();
}
}
7.2 Gerar o mapa: RouteGenerator
// Caminho: Assets/_Project/Scripts/Run/RouteGenerator.cs
using System.Collections.Generic;
using FuryHauler.Data;
using UnityEngine;
namespace FuryHauler.Run
{
// POR QUE: a rota é gerada a cada run (Parte 1 §8).
// ESTRATÉGIA: cria as camadas com 2 a 3 nós (a última com só o chefe) e
// liga camadas vizinhas pela "posição relativa": um nó no alto da coluna
// liga com nós no alto da próxima. Duas passadas garantem que todo nó tem
// pelo menos uma saída e uma entrada; uma ligação extra sorteada cria as
// bifurcações. System.Random (não o UnityEngine.Random) aceita uma
// semente, então o mesmo número gera o mesmo mapa (útil para testar).
public static class RouteGenerator
{
public static RouteMap Generate(GameConfig cfg, System.Random rng)
{
var map = new RouteMap();
int layerCount = Mathf.Max(2, cfg.routeLayers);
for (int l = 0; l < layerCount; l++)
{
bool last = l == layerCount - 1;
int count = last ? 1 : rng.Next(cfg.minNodesPerLayer, cfg.maxNodesPerLayer + 1);
var layer = new List<RouteNode>();
for (int i = 0; i < count; i++)
{
layer.Add(new RouteNode
{
layer = l,
index = i,
isBoss = last,
risk = last ? 0 : rng.Next(0, cfg.maxRisk + 1),
});
}
map.layers.Add(layer);
}
for (int l = 0; l < layerCount - 1; l++) Connect(map.layers[l], map.layers[l + 1], rng);
return map;
}
// Posição relativa (0..1) do nó i numa coluna de "count" nós.
private static float RelativePos(int i, int count) => (i + 0.5f) / count;
// Índice do nó, numa coluna de "count" nós, mais perto da posição relativa.
private static int NearestIndex(float pos, int count) => Mathf.Clamp(Mathf.FloorToInt(pos * count), 0, count - 1);
private static void Connect(List<RouteNode> from, List<RouteNode> to, System.Random rng)
{
// Toda origem tem pelo menos uma saída...
foreach (RouteNode node in from)
{
int nearest = NearestIndex(RelativePos(node.index, from.Count), to.Count);
AddEdge(node, nearest);
// ...e às vezes uma segunda, para um vizinho (a bifurcação).
if (rng.NextDouble() < 0.5)
{
int neighbor = Mathf.Clamp(nearest + (rng.Next(2) == 0 ? -1 : 1), 0, to.Count - 1);
AddEdge(node, neighbor);
}
}
// ...e todo destino tem pelo menos uma entrada.
foreach (RouteNode target in to)
{
int source = NearestIndex(RelativePos(target.index, to.Count), from.Count);
AddEdge(from[source], target.index);
}
}
private static void AddEdge(RouteNode node, int nextIndex)
{
if (!node.next.Contains(nextIndex)) node.next.Add(nextIndex);
}
}
}
7.3 O save permanente: MetaProgress
// Caminho: Assets/_Project/Scripts/Run/MetaProgress.cs
using System;
using UnityEngine;
namespace FuryHauler.Run
{
// POR QUE: o que fica entre runs (Parte 1 §9) precisa sobreviver a fechar
// o navegador. O jogo é offline (Parte 1 §14).
// ESTRATÉGIA: um objeto simples convertido em JSON pelo JsonUtility e
// guardado no PlayerPrefs. Na build Web, a Unity guarda o PlayerPrefs no
// IndexedDB do navegador, mas só grava de fato quando PlayerPrefs.Save()
// é chamado, por isso Save() sempre chama.
[Serializable]
public class MetaProgress
{
private const string Key = "furyhauler.meta";
public int runsStarted;
public int runsCompleted;
public int bestTrips; // maior número de trechos concluídos numa run
public static MetaProgress Load()
{
string json = PlayerPrefs.GetString(Key, "");
if (string.IsNullOrEmpty(json)) return new MetaProgress();
try
{
return JsonUtility.FromJson<MetaProgress>(json) ?? new MetaProgress();
}
catch (ArgumentException)
{
Debug.LogWarning("Save corrompido; começando do zero.");
return new MetaProgress();
}
}
public void Save()
{
PlayerPrefs.SetString(Key, JsonUtility.ToJson(this));
PlayerPrefs.Save();
}
}
}
7.4 Um botão do mapa: RouteNodeButton
// Caminho: Assets/_Project/Scripts/UI/RouteNodeButton.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace FuryHauler.UI
{
// POR QUE: cada nó do mapa vira um botão clicável.
// ESTRATÉGIA: o botão só exibe o texto e chama a ação recebida. Fica
// clicável só se o nó for alcançável, e destacado se for o nó atual.
public class RouteNodeButton : MonoBehaviour
{
[SerializeField] private Button _button;
[SerializeField] private TMP_Text _label;
[SerializeField] private Image _currentFrame;
public void Setup(string text, bool reachable, bool current, Action onClick)
{
_label.text = text;
_button.interactable = reachable;
_currentFrame.enabled = current;
_button.onClick.AddListener(() => onClick());
}
}
}
7.5 O mapa na tela: RouteMapView
// Caminho: Assets/_Project/Scripts/UI/RouteMapView.cs
using System.Text;
using FuryHauler.Run;
using UnityEngine;
namespace FuryHauler.UI
{
// POR QUE: o jogador escolhe o próximo trecho num mapa (Parte 1 §8).
// ESTRATÉGIA: uma coluna por camada (HorizontalLayoutGroup com colunas
// de VerticalLayoutGroup). No MVP as ligações entre nós não são
// desenhadas como linhas: cada botão diz em texto para onde ele segue
// ("Segue: A, B"). Desenhar as linhas fica para depois.
public class RouteMapView : MonoBehaviour
{
[SerializeField] private RunManager _run;
[SerializeField] private Transform _columnsContainer;
[SerializeField] private Transform _columnPrefab;
[SerializeField] private RouteNodeButton _nodePrefab;
// Apaga e recria o mapa inteiro (é pequeno, não vale a pena reaproveitar).
public void Build()
{
foreach (Transform child in _columnsContainer) Destroy(child.gameObject);
foreach (var layer in _run.Route.layers)
{
Transform column = Instantiate(_columnPrefab, _columnsContainer);
foreach (RouteNode node in layer)
{
RouteNodeButton button = Instantiate(_nodePrefab, column);
RouteNode captured = node; // cópia para o "=>" abaixo lembrar deste nó
button.Setup(Describe(node), _run.CanChoose(node), node == _run.CurrentNode,
() => _run.ChooseNode(captured));
}
}
}
// Texto do botão: nome, risco e para onde segue.
private static string Describe(RouteNode node)
{
if (node.isBoss) return "CHEGADA (B)\nChefe";
var text = new StringBuilder();
text.Append($"Trecho {node.Name}\nRisco {node.risk}\nSegue: ");
for (int i = 0; i < node.next.Count; i++)
{
if (i > 0) text.Append(", ");
text.Append((char)('A' + node.next[i]));
}
return text.ToString();
}
}
}
7.6 A run: RunManager
// Caminho: Assets/_Project/Scripts/Run/RunManager.cs
using System.Collections.Generic;
using FuryHauler.Data;
using FuryHauler.Economy;
using FuryHauler.Trip;
using FuryHauler.Truck;
using FuryHauler.UI;
using TMPro;
using UnityEngine;
namespace FuryHauler.Run
{
// POR QUE: costura tudo numa run (Parte 1 §2): começa do zero, escolhe
// trechos no mapa, chega a B ou perde tudo, e guarda a meta-progressão.
// ESTRATÉGIA: uma única cena. Em vez de trocar de cena, liga e desliga
// painéis (menu, mapa, resultado) por cima do jogo. O caminhão é o mesmo
// objeto a run inteira: defesas e sucata continuam de um trecho para o
// outro naturalmente, e o TripManager só o recoloca no começo da estrada.
public class RunManager : MonoBehaviour
{
[SerializeField] private TripManager _trip;
[SerializeField] private TruckController _truck;
[SerializeField] private AllocationController _allocation;
[SerializeField] private ShopBar _shop;
[SerializeField] private List<DefenseDef> _allDefenses = new();
[Header("Telas")]
[SerializeField] private GameObject _menuPanel;
[SerializeField] private TMP_Text _menuStats;
[SerializeField] private GameObject _routePanel;
[SerializeField] private RouteMapView _routeView;
[SerializeField] private GameObject _resultPanel;
[SerializeField] private TMP_Text _resultText;
private MetaProgress _meta;
private int _tripsDone;
public RouteMap Route { get; private set; }
public RouteNode CurrentNode { get; private set; } // null = ainda no ponto A
private void Start()
{
_meta = MetaProgress.Load();
_trip.Ended += OnTripEnded;
ShowOnly(_menuPanel);
RefreshMenu();
}
// Botão "Partir" do menu e "Nova viagem" do resultado.
public void NewRun()
{
GameConfig cfg = GameConfig.I;
Route = RouteGenerator.Generate(cfg, new System.Random());
CurrentNode = null;
_tripsDone = 0;
_allocation.CancelAll();
foreach (TruckSlot slot in _truck.Slots) slot.Clear();
_truck.Health.ResetFull();
ScrapWallet.Instance.ResetTo(cfg.startingScrap);
_shop.SetDefenses(UnlockedDefenses());
_meta.runsStarted++;
_meta.Save();
ShowRoute();
}
// Botão "Menu" do resultado.
public void BackToMenu()
{
RefreshMenu();
ShowOnly(_menuPanel);
}
// Pode ir para este nó? Do ponto A, qualquer nó da camada 0; depois, só
// os ligados ao nó atual.
public bool CanChoose(RouteNode node)
{
if (CurrentNode == null) return node.layer == 0;
return node.layer == CurrentNode.layer + 1 && CurrentNode.next.Contains(node.index);
}
// Clique num nó do mapa: começa o trecho.
public void ChooseNode(RouteNode node)
{
if (!CanChoose(node)) return;
CurrentNode = node;
if (!GameConfig.I.carryTruckHpBetweenTrips) _truck.Health.ResetFull();
ShowOnly(null);
_trip.StartTrip(node.ToSettings(GameConfig.I));
}
private void OnTripEnded(bool success)
{
if (CurrentNode == null) return; // trecho de teste (Fases 2-6), fora de uma run
if (success)
{
_tripsDone++;
_meta.bestTrips = Mathf.Max(_meta.bestTrips, _tripsDone);
}
if (success && CurrentNode.isBoss)
{
_meta.runsCompleted++;
_meta.Save();
ShowResult("Você chegou a B!");
}
else if (success)
{
_meta.Save();
ShowRoute();
}
else
{
_meta.Save();
ShowResult($"O caminhão foi destruído no trecho {CurrentNode.Name}.\nTrechos concluídos: {_tripsDone}");
}
}
// Defesas cujo requisito de desbloqueio já foi atingido (placeholder, Parte 1 §9).
private List<DefenseDef> UnlockedDefenses()
{
var unlocked = new List<DefenseDef>();
foreach (DefenseDef def in _allDefenses)
if (def.unlockAtBestTrips <= _meta.bestTrips) unlocked.Add(def);
return unlocked;
}
private void ShowRoute()
{
ShowOnly(_routePanel);
_routeView.Build();
}
private void ShowResult(string text)
{
_resultText.text = text;
ShowOnly(_resultPanel);
}
private void RefreshMenu()
{
_menuStats.text = $"Viagens: {_meta.runsStarted} Chegadas em B: {_meta.runsCompleted} Melhor run: {_meta.bestTrips} trechos";
}
// Liga um painel e desliga os outros (null = só o jogo).
private void ShowOnly(GameObject panel)
{
_menuPanel.SetActive(panel == _menuPanel);
_routePanel.SetActive(panel == _routePanel);
_resultPanel.SetActive(panel == _resultPanel);
}
}
}
A defesa nova liberada só aparece na barra de compra na próxima run, porque
UnlockedDefenses()é lido noNewRun. É uma escolha de simplicidade, não de design: se o jogo quiser liberar no meio da run, basta chamar_shop.SetDefenses(UnlockedDefenses())também noOnTripEnded.
7.7 Montar na cena
- TripManager: desligue
Start On Play. A partir de agora quem começa os trechos é a run. - Slots: confira que nenhum
Test Defenseestá preenchido. - GameConfig: volte
Trip Lengthpara900, se tinha diminuído na Fase 2. - Run: GameObject
Runcom RunManager:Trip,Truck,Allocation,ShopeAll Defenses= os 4 assets de defesa. - Painéis (filhos do Canvas, cada um com uma Image de fundo escura cobrindo a tela inteira, para bloquear cliques no jogo por baixo):
MenuPanel: título "FURY HAULER", textoMenuStatse botão "Partir" → On Click =Run→RunManager.NewRun.RoutePanel: título "Escolha o próximo trecho" e um objetoColumnscom HorizontalLayoutGroup (Spacing40, Child AlignmentMiddle Center). Ligue RouteMapView noRoutePanel, comRun=RuneColumns Container=Columns.ResultPanel: textoResultText, botão "Nova viagem" →RunManager.NewRune botão "Menu" →RunManager.BackToMenu.
- Prefabs de UI (em
Prefabs/UI/):RouteColumn: um RectTransform vazio com VerticalLayoutGroup (Spacing20, Child AlignmentMiddle Center) e Content Size Fitter (Vertical Fit = Preferred Size).RouteNodeButton: um Button160 × 90com filhosLabel(TextMeshPro) eCurrentFrame(Image só de borda, amarela).
- Na
RouteMapView, ligueColumn Prefab=RouteColumneNode Prefab=RouteNodeButton.
✅ Checkpoint da Fase 7
- O jogo abre no menu, com as estatísticas zeradas na primeira vez.
- "Partir" mostra o mapa: 6 colunas, a última só com "CHEGADA (B)"; só os nós da primeira coluna são clicáveis.
- Escolher um nó esconde o mapa e começa o trecho; ao concluí-lo, o mapa volta, e só os nós indicados em "Segue:" ficam clicáveis.
- Defesas, sucata e vida do caminhão continuam de um trecho para o outro.
- No último trecho aparece o chefe; destruí-lo e chegar ao fim mostra "Você chegou a B!".
- Perder mostra a tela de resultado; "Nova viagem" recomeça com 100 de sucata, caminhão vazio e vida cheia.
- Na primeira run só há metralhadora e blindagem na barra; depois de uma run em que concluiu pelo menos 1 trecho, o lança-chamas aparece na próxima.
- Fechar e abrir o jogo de novo mantém as estatísticas do menu.
Problemas comuns
- Clicar no mapa também clica no caminhão por trás: o painel não tem uma Image de fundo com Raycast Target ligado, então o
EventSystemnão sabe que o mouse está sobre a UI. - O trecho começa sozinho ao abrir o jogo: o
Start On PlaydoTripManagerainda está ligado. - As estatísticas zeram ao rodar de novo no Editor: o PlayerPrefs do Editor é separado do da build, mas persiste entre Plays. Se zerar, confira se o
Save()está sendo chamado (umDebug.Lognele ajuda). Para apagar de propósito: Edit → Clear All PlayerPrefs.
Próxima fase: Build Web e Próximos Passos.
Fase 8 — Build Web e Próximos Passos
Objetivo desta fase: o MVP rodando num navegador, pronto para mandar o link para alguém testar.
8.1 Configurar a plataforma
- File → Build Profiles → Web → Switch Platform.
- Em Player Settings → Resolution and Presentation: resolução padrão
1280 × 720e templateDefault(ouMinimal, sem a moldura da Unity). - Em Player Settings → Publishing Settings:
- Compression Format = Gzip.
- Decompression Fallback ligado. Sem isso, sites que não mandam o cabeçalho
Content-Encodingcerto (como o itch.io em alguns casos) mostram erro ao carregar.
- Run In Background ligado (Resolution and Presentation), para o jogo não congelar quando outra aba ganha foco por um instante.
8.2 Gerar e testar
- Build And Run em
Builds/Web/. A Unity abre um servidor local e o navegador.- Abrir o
index.htmldireto do disco (dando dois cliques) não funciona: navegadores bloqueiam o carregamento dos arquivos da build porfile://.
- Abrir o
- Teste o roteiro inteiro do Checkpoint da Fase 7 no navegador, inclusive fechar a aba e abrir de novo para conferir o save.
- Publicar para teste: no itch.io, crie um projeto HTML, envie a pasta da build compactada em
.zipe marque This file will be played in the browser.
✅ Checkpoint da Fase 8
- A build abre no navegador sem erros no console do navegador (F12).
- Uma run inteira dá para ser jogada só com mouse (e as teclas
1/2opcionais). - O save sobrevive a fechar e abrir a aba.
Problemas comuns
- "Unable to parse Build/....framework.js.gz": o servidor não está mandando os arquivos
.gzcom o cabeçalho certo. Ligue Decompression Fallback e gere de novo. - O save some no navegador: abas anônimas apagam o IndexedDB ao fechar; teste numa aba normal. Na build, o save só é gravado quando
PlayerPrefs.Save()roda (já está noMetaProgress.Save).
8.3 Próximos passos
Tudo abaixo depende de decisões que a Parte 1 ainda marca como Em aberto:
- Arte: decidir pixel art ou vetor e a ferramenta (Parte 1 §11). Os placeholders (losango, quadrado, círculo) foram escolhidos para serem trocados só arrastando sprites novos nos prefabs, sem mexer em código.
- Áudio (Parte 1 §13).
- Decisão de slot mais rica (o maior risco da Parte 1 §16): testar se o leque de tiro por slot cria escolhas de verdade, e considerar inimigos que vêm pela frente ou de um lado só por trecho.
- Tipos de nó no mapa (oficina, loja, evento) e linhas ligando os nós (Parte 1 §8).
- O que desbloquear e como na meta-progressão (Parte 1 §9).
- Coleta de sucata, pausa durante alocação e mover defesas (Parte 1 §5).
- Mais habilidades, defesas e inimigos (Parte 1 §4, §6, §7).
- Protótipo HTML em
prototipos/para testar a sensação do jogo antes de investir em arte, como os outros jogos do repositório.
Versão: rascunho inicial — a refinar conforme prototipagem.