Paper Flight — Guia Unity
Parte 2 — Guia de Implementação Unity
Guia fase a fase para implementar o MVP do Paper Flight em Unity 6. Os números vêm da tabela de placeholders da Parte 1 §12 e ficam em assets (
GameConfig,UpgradeDefinition,MapDefinition), para dar para balancear sem mexer no código.
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:
PaperFlight.Data: ScriptableObjects e tipos de configuração.PaperFlight.Meta: estado do jogador, save e regras de economia.PaperFlight.Flight: avião, input de voo e estilingue.PaperFlight.World: câmera, cenário e elementos do mapa.PaperFlight.Game: oRoundManager, que amarra tudo.PaperFlight.UI: HUD, resultado e hangar.
- Campos do Inspector:
[SerializeField] privatecom_camelCase. Dados de ScriptableObject e de save: campos públicoscamelCase. - Uma cena só (
Game): o hangar e a tela de resultado são painéis de UI por cima da cena de voo. - Textos na tela sem emoji, porque a fonte padrão do TextMeshPro não tem emojis.
Arquitetura em uma página
ProgressManager (save, dinheiro, níveis, mapa atual)
▲ RegisterRound / TryBuy / AdvanceMap
│
HangarPanel ──Lançar──▶ RoundManager ──Prepare/Build──▶ PaperPlane + MapSpawner
UpgradeButton (Hangar → Aiming → Flying → Results)
│ ▲ ▲
LaunchController┘ │ Landed │ AirCurrent / Collectible / Obstacle
(estilingue) │ CoinCollected │ (triggers no mapa)
FlightInput (toque = vento, deslize = nariz)
- Regra de ouro: a física do planeio fica toda em
PaperPlane, e os números vêm de umFlightStatscalculado peloGameConfiga partir dos níveis de upgrade. Mudar o balanceamento é mexer em asset, não em código.
Fase 0 — Setup do Projeto
Objetivo desta fase: um projeto Unity 6 2D vazio, em retrato, organizado e com os pacotes certos, rodando no Play Mode.
0.1 Criar o projeto
- No Unity Hub, instale o Unity 6 LTS com os módulos Android Build Support (e iOS Build Support se for publicar para iPhone).
- New project → template Universal 2D → nome
PaperFlight. - Abra o projeto e confira que a cena padrão roda (botão Play) sem erros no Console.
0.2 Estrutura de pastas
Assets/
_Project/
Scenes/ Game.unity
Scripts/
Data/ UpgradeTrack, FlightStats, UpgradeDefinition, MapDefinition, GameConfig
Meta/ GameState, RoundResult, SaveSystem, ProgressManager
Flight/ PaperPlane, FlightInput, LaunchController, FlightTestLauncher
World/ CameraFollow, ParallaxLayer, GroundFollower, MapSpawner, AirCurrent, Collectible, Obstacle
Game/ RoundManager
UI/ HudView, ResultsPanel, UpgradeButton, HangarPanel, WindPuffEffect
ScriptableObjects/
Upgrades/
Maps/
Prefabs/
Environment/ Env_Map1 ... Env_Map5
MapElements/ AirCurrent_Up, Coin, Obstacle_*
Art/ (sprites do jogo, copiados de Sprites/PaperFlight/, ver 0.5)
Planes/ Backgrounds/ Ground/ Obstacles/ Common/ Icons/
ArtUI/ (kit de UI, ver 0.5)
0.3 Pacotes e configurações
- Window → Package Manager: confira que o Input System está instalado (vem no template). O TextMeshPro faz parte do pacote uGUI no Unity 6; na primeira vez que criar um texto, aceite Import TMP Essentials.
- Edit → Project Settings → Player → Other Settings → Active Input Handling:
Input System Package (New). - Player → Resolution and Presentation → Default Orientation:
Portrait. - Na aba Game, adicione uma resolução fixa
1080x1920(retrato) para testar no Editor. A janela Window → General → Device Simulator também serve para ver em tamanho de celular. - Edit → Project Settings → Tags and Layers: crie a tag
Ground.
0.4 Cena
- Salve a cena atual como
Assets/_Project/Scenes/Game.unitye coloque-a no índice 0 em File → Build Profiles → Scene List.
0.5 Importar a arte do PixelLab
A arte do MVP já está pronta em Sprites/PaperFlight/, neste repositório. A lista completa e os ids do PixelLab estão no README.md de lá, e _preview/maps_all.png mostra os 5 mapas montados. O guia usa esses sprites desde a Fase 3; não há fase de "arte provisória".
- Copie só estes arquivos para o projeto. O resto da pasta (
_raw,_seam,_preview,_tools,_style-test,ui/_*_raw.pnge os tilesets completosground/<n>_<mapa>.png/.json) é material de produção e não vai para a Unity.
De Sprites/PaperFlight/ | Para Assets/_Project/ |
|---|---|
planes/*.png | Art/Planes/ |
backgrounds/*.png (só os da raiz da pasta) | Art/Backgrounds/ |
ground/*_surface.png e ground/*_fill.png | Art/Ground/ |
obstacles/*.png | Art/Obstacles/ |
common/*.png | Art/Common/ |
icons/upgrade_*.png | Art/Icons/ |
ui/panel.png, ui/button_blue.png, ui/button_yellow_square.png, ui/bar_frame.png, ui/bar_fill_white.png | ArtUI/ |
-
Sprites do jogo (tudo em
Art/). Selecione todos de uma vez no Project e, no Inspector:Texture Type= Sprite (2D and UI),Sprite Mode= Single;Pixels Per Unit= 16 (a escala do jogo: 16 px = 1 m);Mesh Type= Full Rect (oDraw Mode= Tiled da Fase 3 exige);Filter Mode= Point (no filter),Compression= None;- Apply.
Para não repetir isso a cada arquivo novo: com uma imagem já configurada, clique no ícone de ajustes no topo do Inspector → Save current to... →
PixelArt16. Em Edit → Project Settings → Preset Manager, adicione o preset na linhaTextureImportercom o filtroglob:"Assets/_Project/Art/**". Todo PNG novo dentro deArt/já entra configurado. -
Kit de UI (
ArtUI/). Ele veio em resolução alta (traço fino), não em 16 px por metro, porque vive no Canvas:Texture Type= Sprite (2D and UI),Pixels Per Unit= 100,Mesh Type= Full Rect,Filter Mode= Point,Compression= None.- Para o painel e os botões esticarem sem deformar os cantos (fatiamento "9-slice"), abra o Sprite Editor e preencha
Border(L, T, R, B, em pixels). Os valores abaixo são de partida: ajuste arrastando as linhas verdes até os cantos arredondados e os parafusos do painel ficarem de fora do miolo.
| Sprite | Border (L, T, R, B) |
|---|---|
panel | 20, 20, 20, 22 |
button_blue | 14, 12, 14, 16 |
button_yellow_square | 14, 12, 14, 16 |
bar_frame, bar_fill_white | sem borda (são usados inteiros) |
- Pivôs. O pivô é o ponto do sprite que fica na posição do GameObject. O padrão (Center) serve para os aviões, a moeda, a corrente de ar, a lufada, os obstáculos de ar e os ícones. Os sprites que ficam apoiados no chão vieram com linhas transparentes embaixo, então o pivô vai no pixel onde o desenho começa; senão eles flutuam. No Sprite Editor,
Pivot= Custom ePivot Unit Mode= Pixels (x a partir da esquerda, y a partir de baixo):
| Sprite | Pivot (x, y) em pixels |
|---|---|
1_quintal_ground_birdhouse | 16, 7 |
2_parque_ground_tree | 16, 6 |
3_praia_ground_palm | 16, 14 |
4_cidade_ground_lamppost | 16, 1 |
5_montanha_ground_pine | 16, 10 |
goal_flag | 8, 1 |
1_quintal_near / 2_parque_near / 3_praia_near / 4_cidade_near / 5_montanha_near | x = largura ÷ 2; y = 1 / 1 / 2 / 0 / 3 |
<n>_<mapa>_far | Pivot = Bottom (não precisa de pixels) |
<n>_<mapa>_surface (topo do chão) | 8, 8 (Parque: 8, 9). É a linha onde a grama, a areia, a calçada ou a neve começa. |
<n>_<mapa>_fill (terra embaixo) | Pivot = Top |
✅ Checkpoint da Fase 0
- O projeto abre sem erros e a cena
Gameroda no Play Mode. - A aba Game mostra uma tela em pé (1080×1920).
- A tag
Groundexiste. - Arrastando
plane_1_quintaldo Project para a cena, ele aparece nítido (sem borrão) e com 2 × 2 unidades.
Problemas comuns
- Erro "You are trying to read Input using the UnityEngine.Input class": o Active Input Handling ficou em
Input Manager (Old). Troque paraInput System Package (New)e reinicie o Editor. - Sprite borrado ou com cores "sujas":
Filter Modeficou em Bilinear ou aCompressionestá ligada. Volte para Point e None.
Próxima fase: Dados e Configuração — os ScriptableObjects com todos os números do jogo.
Fase 1 — Dados e Configuração
Objetivo desta fase: as trilhas de upgrade, os mapas e os números do voo existem como assets editáveis no Inspector.
1.1 UpgradeTrack e FlightStats
// Caminho: Assets/_Project/Scripts/Data/UpgradeTrack.cs
namespace PaperFlight.Data
{
// POR QUE: as cinco trilhas de upgrade (GDD §5.2) precisam de um nome fixo
// no código para o save guardar o nível de cada uma.
// ESTRATÉGIA: um enum com valores explícitos. O valor vira o índice do array
// de níveis no save, então NUNCA reordene; trilhas novas entram no fim.
public enum UpgradeTrack
{
Paper = 0,
Fold = 1,
Launch = 2,
Wind = 3,
Income = 4,
}
}
// Caminho: Assets/_Project/Scripts/Data/FlightStats.cs
namespace PaperFlight.Data
{
// POR QUE: o avião não precisa saber de níveis de upgrade, só dos números
// finais (velocidade de lançamento, arrasto...). Separar os dois deixa a
// física simples e o balanceamento num lugar só (GameConfig).
// ESTRATÉGIA: uma struct de dados puros, recalculada no começo de cada voo.
[System.Serializable]
public struct FlightStats
{
public float launchSpeed; // m/s com força cheia no estilingue
public float drag; // arrasto: quanto o ar freia (por velocidade²)
public float stallSpeed; // abaixo disso o avião não sobe
public float fullClimbSpeed; // acima disso o avião tem o ângulo de subida inteiro
public float maxClimbAngle; // graus
public int windCharges; // impulsos de vento por voo
public float windImpulse; // m/s somados a cada impulso
public float incomeMultiplier; // multiplica o dinheiro da distância
}
}
1.2 UpgradeDefinition
// Caminho: Assets/_Project/Scripts/Data/UpgradeDefinition.cs
using UnityEngine;
namespace PaperFlight.Data
{
// POR QUE: cada trilha de upgrade tem nome, ícone, nível máximo e curva de
// preço próprios, e queremos mudar isso sem recompilar.
// ESTRATÉGIA: um ScriptableObject — um "arquivo de dados" que vive na pasta
// Assets e aparece no Inspector. Criamos um asset por trilha. O efeito do
// upgrade no voo NÃO fica aqui: fica no GameConfig, que junta todas as trilhas.
[CreateAssetMenu(fileName = "Upgrade_", menuName = "Paper Flight/Upgrade")]
public class UpgradeDefinition : ScriptableObject
{
public UpgradeTrack track;
public string displayName = "Upgrade";
public Sprite icon;
[Min(1)] public int maxLevel = 10;
[Min(1)] public int baseCost = 20;
[Min(1f)] public float costGrowth = 1.5f;
[Tooltip("Opcional: um nome por nível, começando no nível 0 (ex.: tipos de papel). " +
"Níveis sem nome mostram 'Nível N'.")]
public string[] tierNames;
// Já chegou no nível máximo?
public bool IsMaxed(int level) => level >= maxLevel;
// Preço para sair do nível atual para o próximo: baseCost × costGrowth^nível.
public int CostForNextLevel(int currentLevel) =>
Mathf.RoundToInt(baseCost * Mathf.Pow(costGrowth, currentLevel));
// Texto do nível para a UI: o nome do tipo, se existir, ou "Nível N".
public string LabelForLevel(int level)
{
if (tierNames != null && level < tierNames.Length && !string.IsNullOrEmpty(tierNames[level]))
return tierNames[level];
return $"Nível {level}";
}
}
}
1.3 MapDefinition
// Caminho: Assets/_Project/Scripts/Data/MapDefinition.cs
using System;
using UnityEngine;
namespace PaperFlight.Data
{
// POR QUE: cada um dos 5 mapas tem meta, cenário e elementos próprios
// (correntes, moedas, obstáculos — GDD §6 e §7).
// ESTRATÉGIA: um ScriptableObject por mapa. Os elementos são sorteados com
// uma semente fixa (seed), então o mapa sai igual em toda rodada.
[CreateAssetMenu(fileName = "Map_", menuName = "Paper Flight/Map")]
public class MapDefinition : ScriptableObject
{
public string displayName = "Mapa";
[Min(1f)] public float goalDistance = 300f;
[Header("Visual")]
[Tooltip("Prefab com chão e camadas de parallax deste mapa.")]
public GameObject environmentPrefab;
[Tooltip("Prefab colocado na distância da meta (ex.: uma bandeira).")]
public GameObject goalMarkerPrefab;
[Tooltip("Sprite do avião deste mapa (cada mapa tem um modelo de dobra, GDD §8).")]
public Sprite planeSprite;
[Tooltip("Cor do céu deste mapa. O céu não é sprite: é a cor de fundo da câmera.")]
public Color skyColor = new Color(0.46f, 0.77f, 0.94f);
[Header("Elementos sorteados")]
public int seed = 1;
[Min(0f)] public float firstSpawnDistance = 15f;
[Min(1f)] public float minGap = 6f;
[Min(1f)] public float maxGap = 14f;
public SpawnEntry[] spawns;
// Sorteia um elemento respeitando os pesos. Retorna null se a lista estiver vazia.
public SpawnEntry PickEntry(System.Random rng)
{
float total = 0f;
foreach (SpawnEntry entry in spawns)
if (entry.prefab != null) total += entry.weight;
if (total <= 0f) return null;
double roll = rng.NextDouble() * total;
SpawnEntry last = null;
foreach (SpawnEntry entry in spawns)
{
if (entry.prefab == null) continue;
last = entry;
roll -= entry.weight;
if (roll <= 0d) return entry;
}
return last; // proteção contra arredondamento
}
}
// POR QUE: cada tipo de elemento tem sua chance e sua faixa de altura
// (moeda no ar, árvore no chão...).
// ESTRATÉGIA: [Serializable] faz a classe aparecer como uma lista editável
// dentro do Inspector do MapDefinition.
[Serializable]
public class SpawnEntry
{
public GameObject prefab;
[Min(0f)] public float weight = 1f;
public float minHeight = 2f;
public float maxHeight = 20f;
}
}
1.4 GameConfig
// Caminho: Assets/_Project/Scripts/Data/GameConfig.cs
using System;
using UnityEngine;
namespace PaperFlight.Data
{
// POR QUE: todos os números de balanceamento do voo (GDD §12) num asset só,
// e a lista de upgrades e mapas do jogo.
// ESTRATÉGIA: BuildStats() transforma "níveis de upgrade" em "números do voo"
// (FlightStats). É o ÚNICO lugar que sabe o que cada trilha faz.
[CreateAssetMenu(fileName = "GameConfig", menuName = "Paper Flight/Game Config")]
public class GameConfig : ScriptableObject
{
[Header("Papel → arrasto")]
public float baseDrag = 0.012f;
[Range(0.5f, 1f)] public float dragMultiplierPerLevel = 0.9f;
[Header("Dobra → sustentação")]
public float baseStallSpeed = 7f;
public float stallSpeedReductionPerLevel = 0.4f;
public float minStallSpeed = 3f;
[Tooltip("Velocidade de subida plena = estol + esta margem.")]
public float fullClimbMargin = 6f;
public float baseMaxClimbAngle = 20f;
public float climbAnglePerLevel = 3f;
[Header("Lançamento")]
public float baseLaunchSpeed = 14f;
public float launchSpeedPerLevel = 2f;
[Header("Vento")]
public int baseWindCharges = 1;
public int windChargesPerLevel = 1;
public float baseWindImpulse = 3f;
public float windImpulsePerLevel = 0.3f;
[Header("Renda e economia")]
public float baseIncomeMultiplier = 1f;
public float incomePerLevel = 0.25f;
public float moneyPerMeter = 1f;
[Header("Conteúdo")]
public UpgradeDefinition[] upgrades;
public MapDefinition[] maps;
// Quantas trilhas existem (tamanho do array de níveis no save).
public int TrackCount => Enum.GetValues(typeof(UpgradeTrack)).Length;
// Converte os níveis de upgrade nos números que o avião usa no voo.
public FlightStats BuildStats(int[] levels)
{
int paper = Level(levels, UpgradeTrack.Paper);
int fold = Level(levels, UpgradeTrack.Fold);
int launch = Level(levels, UpgradeTrack.Launch);
int wind = Level(levels, UpgradeTrack.Wind);
int income = Level(levels, UpgradeTrack.Income);
float stall = Mathf.Max(minStallSpeed, baseStallSpeed - stallSpeedReductionPerLevel * fold);
return new FlightStats
{
launchSpeed = baseLaunchSpeed + launchSpeedPerLevel * launch,
drag = baseDrag * Mathf.Pow(dragMultiplierPerLevel, paper),
stallSpeed = stall,
fullClimbSpeed = stall + fullClimbMargin,
maxClimbAngle = baseMaxClimbAngle + climbAnglePerLevel * fold,
windCharges = baseWindCharges + windChargesPerLevel * wind,
windImpulse = baseWindImpulse + windImpulsePerLevel * wind,
incomeMultiplier = baseIncomeMultiplier + incomePerLevel * income,
};
}
// Lê o nível de uma trilha com segurança (save antigo pode ter array menor).
private static int Level(int[] levels, UpgradeTrack track)
{
int i = (int)track;
return levels != null && i < levels.Length ? levels[i] : 0;
}
// [ContextMenu] cria uma opção no menu "⋮" do Inspector deste asset.
// Útil para conferir os números sem dar Play.
[ContextMenu("Debug: imprimir stats do nível 0 e do nível 10")]
private void DebugPrintStats()
{
int[] zero = new int[TrackCount];
int[] ten = new int[TrackCount];
for (int i = 0; i < ten.Length; i++) ten[i] = 10;
Debug.Log("Nível 0: " + JsonUtility.ToJson(BuildStats(zero), true));
Debug.Log("Nível 10: " + JsonUtility.ToJson(BuildStats(ten), true));
}
}
}
1.5 Criar os assets
- Em
ScriptableObjects/Upgrades: Create → Paper Flight → Upgrade cinco vezes —Upgrade_Paper,Upgrade_Fold,Upgrade_Launch,Upgrade_Wind,Upgrade_Income. Em cada um, escolha oTrackcerto, umDisplay Name(Papel, Dobra, Lançamento, Vento, Renda) e oIcondeArt/Icons/(upgrade_paper,upgrade_fold,upgrade_launch,upgrade_wind,upgrade_income). DeixeTier Namesvazio por enquanto (os nomes de papéis e dobras estão Em aberto, GDD §5.2). - Em
ScriptableObjects/Maps: cincoMapDefinitioncom os valores abaixo eSeeddiferentes. Os campos de prefab (Environment Prefab,Goal Marker Prefab,Spawns) ficam vazios até as Fases 3, 6 e 9.
| Asset | Display Name | Goal Distance | Plane Sprite (Art/Planes/) | Sky Color (hex) |
|---|---|---|---|---|
Map_1 | Quintal | 300 | plane_1_quintal | 76C4F0 |
Map_2 | Parque | 600 | plane_2_parque | 6EBEEE |
Map_3 | Praia | 1000 | plane_3_praia | 5ABAF0 |
Map_4 | Cidade | 1500 | plane_4_cidade | 82B8E2 |
Map_5 | Montanha | 2500 | plane_5_montanha | 78AAE1 |
O hex vai no campo Hexadecimal do seletor de cor. As cores são o topo do céu das prévias; o horizonte, mais claro, fica para um degradê pós-MVP.
3. Em ScriptableObjects: Create → Paper Flight → Game Config. Arraste os 5 upgrades e os 5 mapas (na ordem) para as listas.
✅ Checkpoint da Fase 1
- O projeto compila sem erros.
- No
GameConfig, ⋮ → Debug: imprimir stats... mostra no Console os números do nível 0 (ex.:launchSpeed: 14,stallSpeed: 7) e do nível 10.
Problemas comuns
- O menu "Paper Flight" não aparece em Create: o script tem erro de compilação ou o nome do arquivo é diferente do nome da classe. Confira o Console.
Próxima fase: Progresso e Save — dinheiro, níveis e mapa atual que sobrevivem a fechar o jogo.
Fase 2 — Progresso e Save
Objetivo desta fase: um
ProgressManagerque guarda dinheiro, níveis, recorde e mapa atual, paga as rodadas, vende upgrades e reseta tudo ao trocar de mapa.
2.1 GameState e RoundResult
// Caminho: Assets/_Project/Scripts/Meta/GameState.cs
using System;
namespace PaperFlight.Meta
{
// POR QUE: tudo o que precisa sobreviver a fechar o app (GDD §11).
// ESTRATÉGIA: uma classe só com campos simples, que o JsonUtility da Unity
// sabe transformar em texto JSON e de volta.
[Serializable]
public class GameState
{
public int mapIndex;
public long money;
public int[] levels; // índice = (int)UpgradeTrack
public float bestDistance; // recorde do mapa atual
public bool goalReached; // a meta do mapa atual já foi batida?
// Estado de quem nunca jogou.
public static GameState CreateNew(int trackCount) => new GameState { levels = new int[trackCount] };
// Corrige saves antigos que tinham menos trilhas.
public void EnsureTrackCount(int trackCount)
{
if (levels == null) levels = new int[trackCount];
else if (levels.Length < trackCount) Array.Resize(ref levels, trackCount);
}
// Reset total ao avançar de mapa (GDD §6). Dinheiro e recorde zerarem é placeholder.
public void ResetForMap(int newMapIndex, int trackCount)
{
mapIndex = newMapIndex;
money = 0;
levels = new int[trackCount];
bestDistance = 0f;
goalReached = false;
}
}
}
// Caminho: Assets/_Project/Scripts/Meta/RoundResult.cs
namespace PaperFlight.Meta
{
// POR QUE: a tela de resultado precisa do "extrato" da rodada, não só do total.
// ESTRATÉGIA: struct de dados puros devolvida pelo ProgressManager.RegisterRound.
public struct RoundResult
{
public float distance;
public long distanceMoney;
public int coins;
public long total;
public bool newRecord;
public bool goalJustReached;
}
}
2.2 SaveSystem
// Caminho: Assets/_Project/Scripts/Meta/SaveSystem.cs
using System;
using System.IO;
using UnityEngine;
namespace PaperFlight.Meta
{
// POR QUE: o jogo é offline (GDD §11); o progresso fica num arquivo no aparelho.
// ESTRATÉGIA: classe estática (não precisa estar na cena). Application.persistentDataPath
// é a pasta que a Unity garante que dá para escrever em qualquer plataforma.
public static class SaveSystem
{
private const string FileName = "paperflight_save.json";
private static string FilePath => Path.Combine(Application.persistentDataPath, FileName);
// Grava o estado em JSON.
public static void Save(GameState state)
{
try { File.WriteAllText(FilePath, JsonUtility.ToJson(state)); }
catch (Exception e) { Debug.LogWarning($"Falha ao salvar: {e.Message}"); }
}
// Lê o estado salvo. Retorna null se não existir ou estiver corrompido.
public static GameState Load()
{
if (!File.Exists(FilePath)) return null;
try { return JsonUtility.FromJson<GameState>(File.ReadAllText(FilePath)); }
catch (Exception e)
{
Debug.LogWarning($"Save corrompido, começando do zero: {e.Message}");
return null;
}
}
// Apaga o save (usado pelo menu de debug).
public static void Delete()
{
if (File.Exists(FilePath)) File.Delete(FilePath);
}
}
}
2.3 ProgressManager
// Caminho: Assets/_Project/Scripts/Meta/ProgressManager.cs
using System;
using UnityEngine;
using PaperFlight.Data;
namespace PaperFlight.Meta
{
// POR QUE: as regras de economia (pagar a rodada, comprar upgrade, trocar
// de mapa) precisam morar num lugar só, e a UI precisa saber quando algo mudou.
// ESTRATÉGIA: um MonoBehaviour único na cena, acessível por ProgressManager.Instance
// (padrão "singleton"). Todo método que muda o estado termina em Commit(),
// que salva e dispara o evento Changed para a UI se atualizar.
// [DefaultExecutionOrder(-100)] faz o Awake deste script rodar antes dos
// outros, para Instance já existir quando a UI ligar.
[DefaultExecutionOrder(-100)]
public class ProgressManager : MonoBehaviour
{
public static ProgressManager Instance { get; private set; }
[SerializeField] private GameConfig _config;
public GameConfig Config => _config;
public GameState State { get; private set; }
public event Action Changed;
public MapDefinition CurrentMap => _config.maps[State.mapIndex];
public bool IsLastMap => State.mapIndex >= _config.maps.Length - 1;
public bool CanAdvanceMap => State.goalReached && !IsLastMap;
public FlightStats CurrentStats => _config.BuildStats(State.levels);
// Awake roda uma vez quando o objeto nasce, antes de Start. Aqui carregamos o save.
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
State = SaveSystem.Load() ?? GameState.CreateNew(_config.TrackCount);
State.EnsureTrackCount(_config.TrackCount);
State.mapIndex = Mathf.Clamp(State.mapIndex, 0, _config.maps.Length - 1);
}
// Nível atual de uma trilha.
public int LevelOf(UpgradeTrack track) => State.levels[(int)track];
// Dá para comprar o próximo nível? (não está no máximo e tem dinheiro)
public bool CanBuy(UpgradeDefinition upgrade)
{
int level = LevelOf(upgrade.track);
return !upgrade.IsMaxed(level) && State.money >= upgrade.CostForNextLevel(level);
}
// Compra o próximo nível de uma trilha.
public bool TryBuy(UpgradeDefinition upgrade)
{
if (!CanBuy(upgrade)) return false;
State.money -= upgrade.CostForNextLevel(LevelOf(upgrade.track));
State.levels[(int)upgrade.track]++;
Commit();
return true;
}
// Paga a rodada (distância × Renda + moedas), atualiza recorde e meta (GDD §5.1 e §6).
public RoundResult RegisterRound(float distance, int coins)
{
var result = new RoundResult { distance = distance, coins = coins };
result.distanceMoney = (long)Mathf.Floor(distance * _config.moneyPerMeter * CurrentStats.incomeMultiplier);
result.total = result.distanceMoney + coins;
State.money += result.total;
if (distance > State.bestDistance)
{
result.newRecord = true;
State.bestDistance = distance;
}
if (!State.goalReached && distance >= CurrentMap.goalDistance)
{
State.goalReached = true;
result.goalJustReached = true;
}
Commit();
return result;
}
// Vai para o próximo mapa e reseta todos os upgrades (GDD §6).
public void AdvanceMap()
{
if (!CanAdvanceMap) return;
State.ResetForMap(State.mapIndex + 1, _config.TrackCount);
Commit();
}
// Salva e avisa a UI.
private void Commit()
{
SaveSystem.Save(State);
Changed?.Invoke();
}
// OnApplicationPause é chamado quando o app vai para segundo plano no celular.
private void OnApplicationPause(bool paused)
{
if (paused && State != null) SaveSystem.Save(State);
}
[ContextMenu("Debug: apagar save")]
private void DebugWipe()
{
SaveSystem.Delete();
State = GameState.CreateNew(_config.TrackCount);
Changed?.Invoke();
}
[ContextMenu("Debug: +1000 de dinheiro")]
private void DebugAddMoney()
{
State.money += 1000;
Commit();
}
[ContextMenu("Debug: simular rodada de 350 m")]
private void DebugRound()
{
RoundResult r = RegisterRound(350f, 10);
Debug.Log($"+{r.total} (distância {r.distanceMoney}, moedas {r.coins}) | recorde: {r.newRecord} | meta: {r.goalJustReached} | saldo: {State.money}");
}
}
}
2.4 Montar na cena
- Na cena
Game, crie um GameObject vazioProgressManager, adicione o componente e arraste oGameConfigpara o campoConfig.
✅ Checkpoint da Fase 2
- No Play Mode, no componente
ProgressManager, ⋮ → Debug: simular rodada de 350 m imprime+360 ... meta: Trueno mapa 1 (meta 300 m). - Pare e dê Play de novo: rodando a simulação outra vez, o saldo continua de onde parou (o save funcionou).
- ⋮ → Debug: apagar save zera tudo.
Problemas comuns
NullReferenceExceptionno Awake: o campoConfigestá vazio ou a listaMapsdoGameConfigestá vazia.- Onde fica o arquivo? No Windows, em
%USERPROFILE%\AppData\LocalLow\<Company>\PaperFlight\.Debug.Log(Application.persistentDataPath)mostra o caminho.
Próxima fase: Cena, Câmera e Cenário — chão infinito, parallax e a câmera em retrato.
Fase 3 — Cena, Câmera e Cenário
Objetivo desta fase: uma câmera em retrato que segue um alvo, um chão que nunca acaba e um fundo com parallax, empacotados no prefab de ambiente do Mapa 1.
3.1 Câmera em pixel art
- Selecione a Main Camera:
Projection= Orthographic, posição(0, 8, -10). - Adicione o componente Pixel Perfect Camera (do URP):
Assets Pixels Per Unit= 16,Reference Resolution= 180 × 320 (placeholders da Parte 1 §8). Isso dá uma visão de 20 m de altura por 11,25 m de largura. - Na câmera, Environment → Background Type = Solid Color e
Background=#76C4F0(o céu do Quintal). O céu não é sprite; a partir da Fase 7 oRoundManagertroca essa cor pela do mapa atual.
3.2 CameraFollow
// Caminho: Assets/_Project/Scripts/World/CameraFollow.cs
using UnityEngine;
namespace PaperFlight.World
{
// POR QUE: o avião voa centenas de metros para a direita; a câmera precisa
// acompanhar sem perder o chão de vista.
// ESTRATÉGIA: LateUpdate roda depois de todos os Update, então a câmera se
// move depois do avião no mesmo quadro. O avião fica à esquerda do centro
// (offset) para o jogador ver o que vem pela frente na tela estreita do retrato.
public class CameraFollow : MonoBehaviour
{
[SerializeField] private Transform _target;
[SerializeField] private Vector2 _offset = new Vector2(3f, 1f);
[SerializeField] private float _smoothTime = 0.12f;
[Tooltip("Altura mínima da câmera, para o chão nunca sumir da tela.")]
[SerializeField] private float _minY = 8f;
private Vector3 _velocity;
// Segue o alvo suavemente.
private void LateUpdate()
{
transform.position = Vector3.SmoothDamp(transform.position, Goal(), ref _velocity, _smoothTime);
}
// Pula direto para o alvo (início de rodada), sem a suavização.
public void SnapToTarget()
{
transform.position = Goal();
_velocity = Vector3.zero;
}
// Para onde a câmera quer ir neste quadro.
private Vector3 Goal()
{
Vector3 t = _target.position;
return new Vector3(t.x + _offset.x, Mathf.Max(t.y + _offset.y, _minY), transform.position.z);
}
}
}
3.3 GroundFollower e ParallaxLayer
// Caminho: Assets/_Project/Scripts/World/GroundFollower.cs
using UnityEngine;
namespace PaperFlight.World
{
// POR QUE: um voo pode passar de 2 km; um sprite de chão desse tamanho é
// desperdício.
// ESTRATÉGIA: um pedaço de chão um pouco mais largo que a tela anda junto
// com a câmera, mas "pulando" de tile em tile, então a textura parece parada.
// DefaultExecutionOrder(100) faz este LateUpdate rodar depois do da câmera.
[DefaultExecutionOrder(100)]
public class GroundFollower : MonoBehaviour
{
[Tooltip("Largura de um tile do chão, em unidades.")]
[SerializeField] private float _tileWidth = 1f;
private Transform _camera;
// Start roda uma vez, antes do primeiro quadro. Camera.main acha a câmera com a tag MainCamera.
private void Start() => _camera = Camera.main.transform;
// Encaixa o chão no tile mais próximo da câmera.
private void LateUpdate()
{
Vector3 p = transform.position;
p.x = Mathf.Floor(_camera.position.x / _tileWidth) * _tileWidth;
transform.position = p;
}
}
}
// Caminho: Assets/_Project/Scripts/World/ParallaxLayer.cs
using UnityEngine;
namespace PaperFlight.World
{
// POR QUE: camadas de fundo que andam mais devagar que o chão dão
// profundidade e sensação de velocidade.
// ESTRATÉGIA: factor 0 = a camada fica grudada na câmera (céu); factor 1 = anda
// junto com o mundo (chão). A camada é um sprite repetido 3× na largura e
// "dá a volta" (Mathf.Repeat) a cada tileWidth, então nunca acaba.
[DefaultExecutionOrder(100)]
public class ParallaxLayer : MonoBehaviour
{
[SerializeField, Range(0f, 1f)] private float _factorX = 0.3f;
[SerializeField, Range(0f, 1f)] private float _factorY = 0.1f;
[Tooltip("Largura de UMA repetição da imagem, em unidades.")]
[SerializeField] private float _tileWidth = 20f;
private Transform _camera;
private Vector3 _start;
// Guarda a posição inicial e acha a câmera.
private void Start()
{
_camera = Camera.main.transform;
_start = transform.position;
}
// Reposiciona a camada em relação à câmera.
private void LateUpdate()
{
float camX = _camera.position.x;
float x = camX - Mathf.Repeat(camX * _factorX, _tileWidth);
float y = _start.y + _camera.position.y * (1f - _factorY);
transform.position = new Vector3(x, y, _start.z);
}
}
}
3.4 Montar o prefab Env_Map1 (Quintal)
O cenário de cada mapa é um prefab com o chão e as duas camadas de fundo (far e near). Os sprites vêm de Art/ (Fase 0.5); os pivôs de lá fazem o topo do chão ficar exatamente em y = 0.
- GameObject vazio
Env_Map1em(0, 0, 0). - Filho
Groundem(0, 0): tagGround;BoxCollider2DcomSize= (40, 10) eOffset= (0, -5), para o topo do collider ficar em y = 0;Rigidbody2DcomBody Type= Kinematic (colliders que se movem precisam de Rigidbody);GroundFollowercomTile Width= 1.- Filho
Surfaceem(0, 0):SpriteRenderercom1_quintal_surface,Draw Mode= Tiled,Size= (40, 1). A grama ocupa de y = 0 até y = -0,5. - Filho
Fillem(0, -0.5):SpriteRenderercom1_quintal_fill,Draw Mode= Tiled,Size= (40, 10). A terra começa logo embaixo da grama.
- Filho
- Filho
Nearem(0, 0):SpriteRenderercom1_quintal_near,Draw Mode= Tiled,Size= (47.8125, 8),Order in Layer= -10. AdicioneParallaxLayercomFactor X= 0.5,Factor Y= 1 (fica apoiado no chão) eTile Width= 15.9375. - Filho
Farem(0, 3):SpriteRenderercom1_quintal_far,Draw Mode= Tiled,Size= (47.8125, 8),Order in Layer= -20.ParallaxLayercomFactor X= 0.2,Factor Y= 0.9 eTile Width= 15.9375.- Regra do
Tile Width: é a largura do PNG em pixels ÷ 16 (o Quintal tem 255 px). OSizeX é 3 ×Tile Width, para sempre cobrir a tela enquanto a camada "dá a volta". A tabela dos 5 mapas está na Fase 9.
- Regra do
- Arraste
Env_Map1paraPrefabs/Environmente apague da cena. No assetMap_1, ponha o prefab emEnvironment Prefab. Os mapas 2 a 5 reusam este prefab até a Fase 9. - Para testar agora, coloque uma instância de
Env_Map1na cena, crie um GameObjectTestTargetcom o spriteplane_1_quintale ponhaCameraFollowna Main Camera apontando para ele.
✅ Checkpoint da Fase 3
- No Play Mode, arrastando
TestTargetpara a direita na aba Scene, a câmera segue, o chão não acaba e as camadas de fundo andam em velocidades diferentes. - O fundo do Quintal (cerca, varal, árvores) repete sem emenda visível, e a base dele encosta na grama, sem faixa de céu no meio.
- Subindo o alvo, a câmera sobe; descendo abaixo de y = 7, ela para em y = 8 e o chão continua visível.
Problemas comuns
- O fundo "treme" quando a câmera anda: a Pixel Perfect Camera arredonda a posição para o pixel. Se incomodar, desmarque Pixel Snapping ou teste Upscale Render Texture; vale testar no aparelho antes de decidir.
- Faixa vazia entre repetições do fundo, ou o desenho "pula" ao dar a volta:
Tile Widthdiferente da largura real da imagem. Ele tem que ser exatamente largura em px ÷ 16 (15.9375 no Quintal, não 16). - Aviso "Sprite Tiling might not appear correctly because the Sprite used is not generated with Full Rect": o sprite não está com
Mesh Type= Full Rect (Fase 0.5). - Faixa de céu entre o fundo e o chão, ou grama "enterrada": pivô errado no
*_nearou no*_surface. Confira a tabela de pivôs da Fase 0.5.
Próxima fase: Avião e Planeio — a física do voo, o vento e o controle por deslize.
Fase 4 — Avião e Planeio
Objetivo desta fase: o avião voa sozinho depois de um lançamento de teste, planeia, não consegue subir sem velocidade, recebe impulsos de vento por toque e aceita o controle de nariz por deslize.
4.1 FlightInput
// Caminho: Assets/_Project/Scripts/Flight/FlightInput.cs
using UnityEngine;
using UnityEngine.InputSystem;
namespace PaperFlight.Flight
{
// POR QUE: no voo, o mesmo dedo faz duas coisas: toque = vento, deslize =
// inclinar o nariz (GDD §4.2 e §4.3). Precisamos separar uma da outra.
// ESTRATÉGIA: Pointer.current (Input System) é "o dedo principal OU o mouse",
// então o mesmo código serve para celular e PC. Um toque só vira vento ao
// SOLTAR, se foi curto e quase parado; se o dedo andou, é deslize.
public class FlightInput : MonoBehaviour
{
[SerializeField] private float _tapMaxDuration = 0.2f;
[Tooltip("Quanto o dedo pode andar (fração da altura da tela) e ainda contar como toque.")]
[SerializeField] private float _tapMaxMove = 0.02f;
[Tooltip("Arrastar esta fração da altura da tela dá a inclinação máxima.")]
[SerializeField] private float _fullPitchDrag = 0.15f;
// -1 = nariz todo para baixo, 0 = neutro, +1 = nariz todo para cima.
public float PitchInput { get; private set; }
private bool _tracking;
private bool _movedTooFar;
private bool _tapPending;
private Vector2 _startPos;
private float _startTime;
// OnEnable roda sempre que o componente é ligado. Limpamos o estado para
// que o toque que soltou o estilingue não vire vento.
private void OnEnable()
{
_tracking = false;
_tapPending = false;
PitchInput = 0f;
}
// Lê o ponteiro a cada quadro.
private void Update()
{
Pointer pointer = Pointer.current;
if (pointer == null) return;
if (pointer.press.wasPressedThisFrame)
{
_tracking = true;
_movedTooFar = false;
_startPos = pointer.position.ReadValue();
_startTime = Time.unscaledTime;
}
if (!_tracking) { PitchInput = 0f; return; }
float screenH = Screen.height;
Vector2 delta = pointer.position.ReadValue() - _startPos;
if (delta.magnitude > _tapMaxMove * screenH) _movedTooFar = true;
if (pointer.press.isPressed)
PitchInput = _movedTooFar ? Mathf.Clamp(delta.y / (_fullPitchDrag * screenH), -1f, 1f) : 0f;
if (pointer.press.wasReleasedThisFrame)
{
if (!_movedTooFar && Time.unscaledTime - _startTime <= _tapMaxDuration) _tapPending = true;
_tracking = false;
PitchInput = 0f;
}
}
// Retorna true uma única vez por toque (quem chama "gasta" o toque).
public bool ConsumeTap()
{
if (!_tapPending) return false;
_tapPending = false;
return true;
}
}
}
4.2 PaperPlane
// Caminho: Assets/_Project/Scripts/Flight/PaperPlane.cs
using System;
using UnityEngine;
using PaperFlight.Data;
namespace PaperFlight.Flight
{
// POR QUE: é o coração do jogo — o planeio em que subir custa velocidade e,
// sem velocidade, não se sobe (GDD §4.1).
// ESTRATÉGIA: em vez de deixar a gravidade da Unity agir, controlamos duas
// variáveis — Speed (velocidade) e Pitch (ângulo do nariz) — e a cada passo
// de física calculamos:
// 1. o ângulo desejado (input do jogador, limitado pela velocidade);
// 2. a aceleração ao longo do voo: -g·sen(ângulo) - arrasto·velocidade².
// Nariz para baixo ganha velocidade; nariz para cima perde.
// Depois entregamos o resultado ao Rigidbody2D, que cuida das colisões.
[RequireComponent(typeof(Rigidbody2D))]
public class PaperPlane : MonoBehaviour
{
[SerializeField] private FlightInput _input;
[Header("Atitude (graus)")]
[SerializeField] private float _neutralPitch = -8f;
[SerializeField] private float _maxDiveAngle = 60f;
[Tooltip("Ângulo para onde o nariz cai quando o avião estola.")]
[SerializeField] private float _stallDivePitch = -45f;
[Tooltip("Quantos graus por segundo o nariz consegue girar.")]
[SerializeField] private float _turnRate = 120f;
[Header("Física")]
[SerializeField] private float _gravity = 9.81f;
[SerializeField] private float _minSpeed = 0.5f;
public event Action Landed;
public event Action<int> CoinCollected;
public event Action WindUsed;
public bool IsFlying { get; private set; }
public float Speed { get; private set; }
public float Pitch { get; private set; }
public int WindChargesLeft { get; private set; }
public FlightStats Stats => _stats;
// 0 = sem subida (estol), 1 = subida plena. Usado também pelo velocímetro do HUD.
public float ClimbAbility => Mathf.InverseLerp(_stats.stallSpeed, _stats.fullClimbSpeed, Speed);
private Rigidbody2D _rb;
private FlightStats _stats;
private Vector2 _externalAcceleration;
// Configura o Rigidbody2D: sem gravidade da Unity (a nossa é manual) e sem dormir.
private void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_rb.gravityScale = 0f;
_rb.sleepMode = RigidbodySleepMode2D.NeverSleep;
_rb.interpolation = RigidbodyInterpolation2D.Interpolate;
}
// Coloca o avião parado no ponto de lançamento com os números do voo.
public void Prepare(Vector2 position, FlightStats stats)
{
_stats = stats;
IsFlying = false;
Speed = 0f;
Pitch = 0f;
WindChargesLeft = stats.windCharges;
_externalAcceleration = Vector2.zero;
_rb.linearVelocity = Vector2.zero;
_rb.angularVelocity = 0f;
_rb.position = position;
_rb.rotation = 0f;
transform.SetPositionAndRotation(position, Quaternion.identity);
}
// Gira o avião parado enquanto o jogador mira o estilingue.
public void Aim(float angleDeg)
{
if (IsFlying) return;
Pitch = angleDeg;
_rb.rotation = angleDeg;
transform.rotation = Quaternion.Euler(0f, 0f, angleDeg);
}
// Solta o avião. power01 vem do estilingue (0 a 1).
public void Launch(float angleDeg, float power01)
{
Pitch = angleDeg;
Speed = Mathf.Max(_minSpeed, _stats.launchSpeed * Mathf.Clamp01(power01));
IsFlying = true;
}
// Correntes de ar somam aceleração aqui; ela é aplicada no próximo passo de física.
public void AddExternalAcceleration(Vector2 acceleration)
{
if (IsFlying) _externalAcceleration += acceleration;
}
// Obstáculo: perde uma fração da velocidade (placeholder do GDD §7).
public void HitObstacle(float speedLoss01)
{
if (IsFlying) Speed *= 1f - Mathf.Clamp01(speedLoss01);
}
// Moeda coletada: só repassa o valor para quem conta o dinheiro da rodada.
public void Collect(int value)
{
if (IsFlying) CoinCollected?.Invoke(value);
}
// Update roda a cada quadro (input); FixedUpdate roda em passos fixos (física).
private void Update()
{
if (IsFlying && _input != null && _input.ConsumeTap()) UseWind();
}
// Gasta uma carga de vento e ganha velocidade (GDD §4.2).
private void UseWind()
{
if (WindChargesLeft <= 0) return;
WindChargesLeft--;
Speed += _stats.windImpulse;
WindUsed?.Invoke();
}
// Um passo da física do planeio.
private void FixedUpdate()
{
if (!IsFlying)
{
_rb.linearVelocity = Vector2.zero;
_rb.angularVelocity = 0f;
return;
}
float dt = Time.fixedDeltaTime;
// 1. O nariz gira em direção ao ângulo desejado, sem teleportar.
Pitch = Mathf.MoveTowards(Pitch, TargetPitch(), _turnRate * dt);
// 2. Troca de altura por velocidade, mais o arrasto do ar.
float rad = Pitch * Mathf.Deg2Rad;
float acceleration = -_gravity * Mathf.Sin(rad) - _stats.drag * Speed * Speed;
Speed = Mathf.Max(_minSpeed, Speed + acceleration * dt);
Vector2 velocity = new Vector2(Mathf.Cos(rad), Mathf.Sin(rad)) * Speed;
// 3. Correntes de ar empurram o vetor de velocidade e mudam a direção do nariz.
if (_externalAcceleration != Vector2.zero)
{
velocity += _externalAcceleration * dt;
_externalAcceleration = Vector2.zero;
Speed = Mathf.Max(_minSpeed, velocity.magnitude);
Pitch = Mathf.Clamp(Mathf.Atan2(velocity.y, velocity.x) * Mathf.Rad2Deg, -80f, 80f);
}
_rb.linearVelocity = velocity;
_rb.angularVelocity = 0f;
_rb.MoveRotation(Pitch);
}
// O ângulo que o avião quer ter agora: a regra "sem velocidade não sobe".
private float TargetPitch()
{
if (Speed <= _stats.stallSpeed) return _stallDivePitch; // estol: embica
float maxUp = _stats.maxClimbAngle * ClimbAbility;
float input = _input != null ? _input.PitchInput : 0f;
return input >= 0f
? Mathf.Lerp(_neutralPitch, maxUp, input)
: Mathf.Lerp(_neutralPitch, -_maxDiveAngle, -input);
}
// OnCollisionEnter2D é chamado pela Unity quando este collider bate em outro sólido.
private void OnCollisionEnter2D(Collision2D collision)
{
if (!IsFlying || !collision.collider.CompareTag("Ground")) return;
IsFlying = false;
Speed = 0f;
_rb.linearVelocity = Vector2.zero;
Landed?.Invoke();
}
}
}
⚠️ Nota de versão:
rb.linearVelocityé o nome usado no Unity 6 / 2023.x+. Se o projeto rodar em 2022 LTS, troque porrb.velocity(mesma coisa, nome antigo).
4.3 FlightTestLauncher (só para testar esta fase)
// Caminho: Assets/_Project/Scripts/Flight/FlightTestLauncher.cs
using UnityEngine;
using UnityEngine.InputSystem;
using PaperFlight.Data;
namespace PaperFlight.Flight
{
// POR QUE: o estilingue só chega na Fase 5, mas o voo precisa ser testado já.
// ESTRATÉGIA: Espaço lança com força cheia num ângulo fixo; R volta ao início.
// Usa os números do nível 0. Remova (ou desligue) este componente na Fase 7.
public class FlightTestLauncher : MonoBehaviour
{
[SerializeField] private PaperPlane _plane;
[SerializeField] private GameConfig _config;
[SerializeField] private Transform _launchPoint;
[SerializeField] private float _angle = 30f;
// Começa com o avião pronto no ponto de lançamento.
private void Start() => ResetPlane();
// Lê o teclado do teste.
private void Update()
{
Keyboard kb = Keyboard.current;
if (kb == null) return;
if (kb.spaceKey.wasPressedThisFrame && !_plane.IsFlying) _plane.Launch(_angle, 1f);
if (kb.rKey.wasPressedThisFrame) ResetPlane();
}
// Volta o avião para o ponto de lançamento, mirando no ângulo de teste.
private void ResetPlane()
{
_plane.Prepare(_launchPoint.position, _config.BuildStats(new int[_config.TrackCount]));
_plane.Aim(_angle);
}
}
}
4.4 Montar o avião
- GameObject
PaperPlanecomSpriteRendererusandoplane_1_quintal(2 × 2 unidades, nariz para a direita) eOrder in Layer= 10, para ficar na frente de tudo. Na Fase 7 oRoundManagertroca o sprite pelo avião do mapa atual. - Adicione
Rigidbody2D(Body Type= Dynamic; os outros campos o script ajusta),CapsuleCollider2D(Direction = Horizontal,Size= (1.5, 1)) ePaperPlane. O desenho dos 5 aviões ocupa uns 25 × 17–20 px no quadro de 32 px, então o mesmo collider serve para todos. - No mesmo objeto, adicione
FlightInpute arraste-o para o campoInputdoPaperPlane. - GameObject vazio
LaunchPointem(0, 2). - GameObject
FlightTestcomFlightTestLauncher(Plane, Config, Launch Point preenchidos). - Na Main Camera, troque o alvo do
CameraFollowdeTestTargetparaPaperPlane(e apague oTestTarget).
✅ Checkpoint da Fase 4
- Play → Espaço: o avião sai a 30°, perde velocidade, inclina para baixo e planeia até o chão, onde para.
- Segurando o clique e arrastando para cima logo no começo do voo, o avião sobe; segurando para cima o tempo todo, ele vai perdendo velocidade, deixa de subir e embica (estol). Essa é a regra do GDD §4.1 funcionando.
- Um clique rápido no meio do voo dá um "empurrão" (1 carga no nível 0); o segundo clique não faz nada.
- R volta o avião ao ponto de lançamento.
Problemas comuns
- O avião atravessa o chão: o chão está sem a tag
Ground, sem collider, ou oBoxCollider2Dnão está no topo do sprite. Em voos muito rápidos, ponha oCollision DetectiondoRigidbody2Ddo avião emContinuous. - O nariz não gira no voo: Constraints → Freeze Rotation Z está marcado no
Rigidbody2D. Desmarque; o script já zera a rotação causada por física a cada passo. - O voo parece lento ou rápido demais: ajuste o
GameConfig(velocidade de lançamento, arrasto) e o_neutralPitch. Lembre que os números são placeholders.
Próxima fase: Lançamento Estilingue — puxar e soltar para lançar.
Fase 5 — Lançamento Estilingue
Objetivo desta fase: o jogador lança o avião arrastando para trás e soltando, com força e ângulo definidos pelo arrasto (GDD §3).
5.1 LaunchController
// Caminho: Assets/_Project/Scripts/Flight/LaunchController.cs
using System;
using UnityEngine;
using UnityEngine.InputSystem;
namespace PaperFlight.Flight
{
// POR QUE: o lançamento é um gesto de estilingue: puxar para trás e soltar.
// ESTRATÉGIA: guardamos onde o dedo encostou; o vetor "ponto inicial − ponto
// atual" aponta para onde o avião vai. O tamanho desse vetor (em metros do
// mundo) vira a força, de 0 a 1. Uma LineRenderer mostra a mira.
public class LaunchController : MonoBehaviour
{
[SerializeField] private PaperPlane _plane;
[SerializeField] private Camera _camera;
[SerializeField] private LineRenderer _aimLine;
[Header("Estilingue")]
[Tooltip("Arrastar esta distância (em unidades do mundo) dá força cheia.")]
[SerializeField] private float _maxPullDistance = 3f;
[Tooltip("Abaixo desta força o lançamento é cancelado.")]
[SerializeField, Range(0f, 1f)] private float _minPower = 0.15f;
[SerializeField] private float _minAngle = 0f;
[SerializeField] private float _maxAngle = 75f;
[SerializeField] private float _aimLineMaxLength = 4f;
public event Action Launched;
private bool _dragging;
private Vector2 _pressWorld;
// Ao ligar ou desligar, cancela qualquer arrasto pela metade.
private void OnEnable() { _dragging = false; SetLineVisible(false); }
private void OnDisable() { _dragging = false; SetLineVisible(false); }
// Acompanha o arrasto e lança ao soltar.
private void Update()
{
Pointer pointer = Pointer.current;
if (pointer == null) return;
Vector2 world = ScreenToWorld(pointer.position.ReadValue());
if (pointer.press.wasPressedThisFrame)
{
_dragging = true;
_pressWorld = world;
}
if (!_dragging) return;
Vector2 pull = _pressWorld - world;
float power = Mathf.Clamp01(pull.magnitude / _maxPullDistance);
float angle = pull.sqrMagnitude > 0.0001f
? Mathf.Clamp(Mathf.Atan2(pull.y, pull.x) * Mathf.Rad2Deg, _minAngle, _maxAngle)
: _minAngle;
_plane.Aim(angle);
DrawAimLine(angle, power);
if (pointer.press.wasReleasedThisFrame)
{
_dragging = false;
SetLineVisible(false);
if (power >= _minPower)
{
_plane.Launch(angle, power);
Launched?.Invoke();
}
}
}
// Converte a posição na tela (pixels) para o mundo (metros).
private Vector2 ScreenToWorld(Vector2 screen) =>
_camera.ScreenToWorldPoint(new Vector3(screen.x, screen.y, -_camera.transform.position.z));
// Desenha a mira saindo do avião: direção = ângulo, comprimento = força.
private void DrawAimLine(float angle, float power)
{
SetLineVisible(power >= _minPower);
float rad = angle * Mathf.Deg2Rad;
Vector3 start = _plane.transform.position;
Vector3 end = start + new Vector3(Mathf.Cos(rad), Mathf.Sin(rad), 0f) * (_aimLineMaxLength * power);
_aimLine.positionCount = 2;
_aimLine.SetPosition(0, start);
_aimLine.SetPosition(1, end);
}
// Liga/desliga a linha com segurança (o campo pode estar vazio).
private void SetLineVisible(bool visible)
{
if (_aimLine != null) _aimLine.enabled = visible;
}
}
}
5.2 Montar
- GameObject
LaunchercomLaunchController. Crie um filhoAimLinecomLineRenderer:Use World Spacemarcado,Width0,1, materialSprite-Lit-Default, cor branca. PreenchaPlane,CameraeAim Line. - Deixe o
FlightTestLauncherligado nesta fase só para preparar o avião (oStartdele chamaPrepare; R reposiciona). Não use o Espaço: quem lança agora é o estilingue. A partir da Fase 7, oRoundManagerassume oPrepare.
✅ Checkpoint da Fase 5
- Arrastando para baixo e para a esquerda, o avião gira para cima e a linha cresce com o arrasto.
- Soltando, o avião sai na direção da linha; um arrasto curtinho não lança.
- Arrastando para a direita (direção errada), o ângulo trava em 75°; para cima e para a esquerda, trava em 0°.
Problemas comuns
- O voo começa e o avião logo recebe um impulso de vento: o
FlightInputestava ligado durante a mira e contou o soltar do estilingue como toque. Na Fase 7 oRoundManagersó liga oFlightInputdepois do lançamento; até lá, ignore. - A linha não aparece: o
LineRendererestá atrás do cenário. AjusteOrder in Layer(aba Additional Settings) para um valor alto.
Próxima fase: Elementos do Mapa — correntes de ar, moedas, obstáculos e a meta.
Fase 6 — Elementos do Mapa
Objetivo desta fase: correntes de ar, moedas e obstáculos aparecem à frente do avião sempre no mesmo lugar para cada mapa, e uma bandeira marca a meta.
6.1 AirCurrent, Collectible, Obstacle
// Caminho: Assets/_Project/Scripts/World/AirCurrent.cs
using UnityEngine;
using PaperFlight.Flight;
namespace PaperFlight.World
{
// POR QUE: zonas que empurram o avião para cima ou para baixo (GDD §7).
// ESTRATÉGIA: um collider marcado como "Is Trigger" não bloqueia nada, só
// avisa quem está dentro. OnTriggerStay2D é chamado a cada passo de física
// enquanto o avião estiver na zona.
[RequireComponent(typeof(Collider2D))]
public class AirCurrent : MonoBehaviour
{
[Tooltip("Aceleração em m/s². (0, 12) empurra para cima; (0, -8) para baixo.")]
[SerializeField] private Vector2 _acceleration = new Vector2(0f, 12f);
// Empurra o avião enquanto ele estiver dentro.
private void OnTriggerStay2D(Collider2D other)
{
if (other.attachedRigidbody != null && other.attachedRigidbody.TryGetComponent(out PaperPlane plane))
plane.AddExternalAcceleration(_acceleration);
}
}
}
// Caminho: Assets/_Project/Scripts/World/Collectible.cs
using UnityEngine;
using PaperFlight.Flight;
namespace PaperFlight.World
{
// POR QUE: moedas no caminho somam dinheiro à rodada (GDD §7).
// ESTRATÉGIA: trigger; ao encostar, entrega o valor ao avião e se esconde.
// O MapSpawner recria tudo na próxima rodada.
[RequireComponent(typeof(Collider2D))]
public class Collectible : MonoBehaviour
{
[SerializeField, Min(1)] private int _value = 5;
// OnTriggerEnter2D: chamado uma vez quando algo entra no trigger.
private void OnTriggerEnter2D(Collider2D other)
{
if (other.attachedRigidbody == null || !other.attachedRigidbody.TryGetComponent(out PaperPlane plane)) return;
if (!plane.IsFlying) return;
plane.Collect(_value);
gameObject.SetActive(false);
}
}
}
// Caminho: Assets/_Project/Scripts/World/Obstacle.cs
using UnityEngine;
using PaperFlight.Flight;
namespace PaperFlight.World
{
// POR QUE: árvores, prédios, pássaros no caminho (GDD §7). O efeito está
// Em aberto; o placeholder é frear o avião.
// ESTRATÉGIA: trigger que aplica a perda de velocidade uma única vez. Se a
// decisão virar "derruba o avião", é só trocar HitObstacle por um fim de voo.
[RequireComponent(typeof(Collider2D))]
public class Obstacle : MonoBehaviour
{
[SerializeField, Range(0f, 1f)] private float _speedLoss = 0.4f;
private bool _hit;
// Freia o avião na primeira vez que ele encosta.
private void OnTriggerEnter2D(Collider2D other)
{
if (_hit) return;
if (other.attachedRigidbody == null || !other.attachedRigidbody.TryGetComponent(out PaperPlane plane)) return;
_hit = true;
plane.HitObstacle(_speedLoss);
}
}
}
6.2 MapSpawner
// Caminho: Assets/_Project/Scripts/World/MapSpawner.cs
using System.Collections.Generic;
using UnityEngine;
using PaperFlight.Data;
namespace PaperFlight.World
{
// POR QUE: um mapa de 2,5 km com tudo instanciado de uma vez é pesado no
// celular, e montar tudo à mão nos 5 mapas dá muito trabalho no MVP.
// ESTRATÉGIA: criamos os elementos só um pouco à frente da câmera e
// destruímos os que ficaram para trás. System.Random com a semente do mapa
// sorteia sempre a mesma sequência, então o mapa é igual em toda rodada
// (placeholder do GDD §6).
public class MapSpawner : MonoBehaviour
{
[SerializeField] private Transform _camera;
[SerializeField] private float _spawnAhead = 30f;
[SerializeField] private float _despawnBehind = 15f;
private readonly List<GameObject> _spawned = new List<GameObject>();
private MapDefinition _map;
private System.Random _rng;
private float _nextX;
private GameObject _goalMarker;
// Recomeça o mapa do zero para uma rodada nova.
public void Build(MapDefinition map, float startX)
{
Clear();
_map = map;
_rng = new System.Random(map.seed);
_nextX = startX + map.firstSpawnDistance;
if (map.goalMarkerPrefab != null)
_goalMarker = Instantiate(map.goalMarkerPrefab, new Vector3(startX + map.goalDistance, 0f, 0f), Quaternion.identity, transform);
}
// Destrói tudo o que foi criado.
public void Clear()
{
foreach (GameObject go in _spawned)
if (go != null) Destroy(go);
_spawned.Clear();
if (_goalMarker != null) Destroy(_goalMarker);
_map = null;
}
// Cria o que falta à frente e limpa o que ficou para trás.
private void Update()
{
if (_map == null) return;
float camX = _camera.position.x;
while (_nextX < camX + _spawnAhead) SpawnNext();
for (int i = _spawned.Count - 1; i >= 0; i--)
{
GameObject go = _spawned[i];
if (go == null || go.transform.position.x < camX - _despawnBehind)
{
if (go != null) Destroy(go);
_spawned.RemoveAt(i);
}
}
}
// Sorteia um elemento, sua altura e a distância até o próximo.
private void SpawnNext()
{
SpawnEntry entry = _map.PickEntry(_rng);
if (entry != null)
{
float height = Mathf.Lerp(entry.minHeight, entry.maxHeight, (float)_rng.NextDouble());
_spawned.Add(Instantiate(entry.prefab, new Vector3(_nextX, height, 0f), Quaternion.identity, transform));
}
_nextX += Mathf.Max(1f, Mathf.Lerp(_map.minGap, _map.maxGap, (float)_rng.NextDouble()));
}
}
}
6.3 Prefabs
Todos em Prefabs/MapElements, com sprites de Art/ (Fase 0.5). A ordem de desenho fica: fundo (-20, -10) → bandeira (3) → obstáculos (4) → corrente (5) → moeda (6) → avião (10).
AirCurrent_Up(comum a todos os mapas):SpriteRenderercomair_current(3 × 8 unidades),Order in Layer= 5 eColorcom alfa 150 (o desenho é opaco demais para ficar por cima do cenário);BoxCollider2Dcom Is Trigger eSize= (2.2, 7) (o redemoinho ocupa ~35 × 111 px);AirCurrentcom aceleração(0, 12).Coin(comum):SpriteRenderercomcoin(1 × 1 unidade),Order in Layer= 6;CircleCollider2Dcom Is Trigger eRadius= 0.45;Collectible.GoalFlag(comum):SpriteRenderercomgoal_flag,Order in Layer= 3, sem collider. O pivô da Fase 0.5 apoia o mastro no chão.- Obstáculos do Mapa 1:
Obstacle_Birdhouse:SpriteRenderercom1_quintal_ground_birdhouse,Order in Layer= 4;PolygonCollider2Dcom Is Trigger (ele gera o contorno a partir do desenho, então o avião só bate onde há pixel);Obstacle.Obstacle_Sparrow: igual, com1_quintal_air_sparrow.- Os obstáculos dos mapas 2 a 5 seguem o mesmo molde (Fase 9).
- No
Map_1:Goal Marker Prefab=GoalFlag;Spawns:
| Prefab | Peso | Altura mín. | Altura máx. |
|---|---|---|---|
| Coin | 3 | 2 | 15 |
| AirCurrent_Up | 1 | 4 | 4 |
| Obstacle_Birdhouse | 1 | 0 | 0 |
| Obstacle_Sparrow | 0,5 | 6 | 18 |
Obstáculo de chão usa altura 0: o pivô (Fase 0.5) coloca a base no chão. A corrente tem o pivô no centro, então altura 4 faz ela ir do chão (0) até 8 m.
- Na cena: GameObject
MapSpawnercom o componente,Camera= Main Camera. Quem chamaBuildé oRoundManager(Fase 7), então o teste desta fase acontece lá.
✅ Checkpoint da Fase 6
- (Conferido na Fase 7, quando o
RoundManagerchamarBuild.) Moedas, correntes e obstáculos aparecem à frente e somem atrás; voltando ao início (nova rodada), aparecem nos mesmos lugares. - Passando por uma corrente para cima, o avião ganha altura e velocidade.
- Encostando na casinha de passarinho, o avião perde velocidade uma vez só; passar perto, no vazio do desenho, não conta.
- A casinha e a bandeira ficam apoiadas no chão, sem flutuar.
Problemas comuns
- O avião passa pela corrente e nada acontece: um dos dois objetos precisa ter
Rigidbody2D(o avião tem) e o collider da corrente precisa estar com Is Trigger. Confira também se o avião não "dormiu" — o script já põeNeverSleep. - O Editor trava ao dar Play:
minGap/maxGapem 0 fariam o loop do spawner nunca terminar; o código força pelo menos 1 m, mas confira os valores do mapa.
Próxima fase: Rodada, HUD e Resultados — o ciclo lançar → voar → receber.
Fase 7 — Rodada, HUD e Resultados
Objetivo desta fase: o jogo roda o ciclo completo — mirar, voar, pousar, ver o resultado e ganhar dinheiro — com HUD de voo.
7.1 HudView
// Caminho: Assets/_Project/Scripts/UI/HudView.cs
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using PaperFlight.Flight;
namespace PaperFlight.UI
{
// POR QUE: durante o voo o jogador precisa ver distância, meta, cargas de
// vento e — principalmente — se tem velocidade para subir (GDD §9).
// ESTRATÉGIA: só mostra; quem chama Refresh é o RoundManager. O velocímetro
// muda de cor conforme a regra do planeio (vermelho/amarelo/verde).
public class HudView : MonoBehaviour
{
[SerializeField] private TMP_Text _distanceText;
[SerializeField] private TMP_Text _goalText;
[SerializeField] private TMP_Text _windText;
[SerializeField] private Image _speedFill;
[SerializeField] private Color _stallColor = new Color(0.9f, 0.25f, 0.2f);
[SerializeField] private Color _partialColor = new Color(0.95f, 0.8f, 0.2f);
[SerializeField] private Color _fullColor = new Color(0.3f, 0.85f, 0.35f);
// Liga o HUD com a meta e o recorde do mapa.
public void Show(float goalDistance, float bestDistance)
{
gameObject.SetActive(true);
_goalText.text = $"Meta {goalDistance:0} m | Recorde {bestDistance:0} m";
}
// Esconde o HUD.
public void Hide() => gameObject.SetActive(false);
// Atualiza os números do voo.
public void Refresh(float distance, PaperPlane plane)
{
_distanceText.text = $"{Mathf.Max(0f, distance):0} m";
_windText.text = $"Vento x{plane.WindChargesLeft}";
float maxShown = plane.Stats.fullClimbSpeed * 1.5f;
_speedFill.fillAmount = maxShown > 0f ? Mathf.Clamp01(plane.Speed / maxShown) : 0f;
_speedFill.color = plane.Speed <= plane.Stats.stallSpeed ? _stallColor
: plane.ClimbAbility >= 1f ? _fullColor
: _partialColor;
}
}
}
7.2 ResultsPanel
// Caminho: Assets/_Project/Scripts/UI/ResultsPanel.cs
using TMPro;
using UnityEngine;
using PaperFlight.Meta;
namespace PaperFlight.UI
{
// POR QUE: depois do pouso o jogador vê o "extrato" da rodada (GDD §9).
// ESTRATÉGIA: um painel que só preenche textos. O botão "Continuar" é ligado
// no Inspector ao RoundManager.ShowHangar.
public class ResultsPanel : MonoBehaviour
{
[SerializeField] private TMP_Text _distanceText;
[SerializeField] private TMP_Text _moneyText;
[SerializeField] private TMP_Text _extraText;
// Mostra o resultado da rodada.
public void Show(RoundResult result, float goalDistance, bool isLastMap)
{
gameObject.SetActive(true);
_distanceText.text = $"{result.distance:0} m" + (result.newRecord ? "\nNOVO RECORDE!" : "");
_moneyText.text = $"Distância: +{result.distanceMoney}\nMoedas: +{result.coins}\nTotal: +{result.total}";
if (result.goalJustReached)
_extraText.text = isLastMap
? "Você completou o último mapa!"
: "Meta batida! O próximo mapa foi liberado no hangar.";
else
_extraText.text = $"Meta do mapa: {goalDistance:0} m";
}
// Esconde o painel.
public void Hide() => gameObject.SetActive(false);
}
}
7.3 RoundManager
// Caminho: Assets/_Project/Scripts/Game/RoundManager.cs
using UnityEngine;
using PaperFlight.Data;
using PaperFlight.Flight;
using PaperFlight.Meta;
using PaperFlight.UI;
using PaperFlight.World;
namespace PaperFlight.Game
{
// POR QUE: alguém precisa conduzir o ciclo da rodada (GDD §2) e ligar/desligar
// cada peça na hora certa: o estilingue só na mira, o input de voo só no voo.
// ESTRATÉGIA: uma máquina de estados simples (Phase). Cada transição é um
// método público curto. O hangar é só um GameObject para esta classe — se
// ainda não existir (antes da Fase 8), o jogo vai direto para a mira.
public class RoundManager : MonoBehaviour
{
public enum Phase { Hangar, Aiming, Flying, Results }
[Header("Voo")]
[SerializeField] private PaperPlane _plane;
[SerializeField] private SpriteRenderer _planeRenderer;
[SerializeField] private FlightInput _flightInput;
[SerializeField] private LaunchController _launch;
[SerializeField] private Transform _launchPoint;
[Header("Mundo")]
[SerializeField] private CameraFollow _cameraFollow;
[SerializeField] private Camera _camera;
[SerializeField] private MapSpawner _spawner;
[SerializeField] private Transform _environmentRoot;
[Header("UI")]
[SerializeField] private HudView _hud;
[SerializeField] private ResultsPanel _results;
[SerializeField] private GameObject _hangarRoot;
public Phase CurrentPhase { get; private set; }
private int _coins;
private GameObject _environment;
private MapDefinition _loadedMap;
private ProgressManager Progress => ProgressManager.Instance;
private float Distance => _plane.transform.position.x - _launchPoint.position.x;
// Assina os eventos (+=) e abre o hangar.
private void Start()
{
_plane.Landed += OnLanded;
_plane.CoinCollected += OnCoinCollected;
_launch.Launched += OnLaunched;
LoadMapEnvironment();
ShowHangar();
}
// Cancela as assinaturas (-=) quando o objeto é destruído.
private void OnDestroy()
{
if (_plane != null) { _plane.Landed -= OnLanded; _plane.CoinCollected -= OnCoinCollected; }
if (_launch != null) _launch.Launched -= OnLaunched;
}
// Atualiza o HUD durante o voo.
private void Update()
{
if (CurrentPhase == Phase.Flying) _hud.Refresh(Distance, _plane);
}
// Volta ao hangar (também chamado pelo botão "Continuar" do resultado).
public void ShowHangar()
{
CurrentPhase = Phase.Hangar;
_results.Hide();
_hud.Hide();
_launch.enabled = false;
_flightInput.enabled = false;
ResetPlaneAndMap();
if (_hangarRoot != null) _hangarRoot.SetActive(true);
else StartAiming();
}
// Começa a mira (botão "Lançar" do hangar).
public void StartAiming()
{
if (_hangarRoot != null) _hangarRoot.SetActive(false);
_results.Hide();
ResetPlaneAndMap();
_hud.Show(Progress.CurrentMap.goalDistance, Progress.State.bestDistance);
_hud.Refresh(0f, _plane);
_launch.enabled = true;
CurrentPhase = Phase.Aiming;
}
// Troca de mapa com reset total (botão "Próximo mapa" do hangar).
public void AdvanceMap()
{
Progress.AdvanceMap();
LoadMapEnvironment();
ShowHangar();
}
// Avião no ponto de lançamento com os upgrades atuais; mapa recriado do zero.
private void ResetPlaneAndMap()
{
_coins = 0;
_plane.Prepare(_launchPoint.position, Progress.CurrentStats);
_spawner.Build(Progress.CurrentMap, _launchPoint.position.x);
_cameraFollow.SnapToTarget();
}
// Troca cenário, avião e cor do céu quando o mapa muda.
private void LoadMapEnvironment()
{
if (_loadedMap == Progress.CurrentMap) return;
if (_environment != null) Destroy(_environment);
_loadedMap = Progress.CurrentMap;
if (_loadedMap.environmentPrefab != null)
_environment = Instantiate(_loadedMap.environmentPrefab, _environmentRoot);
if (_loadedMap.planeSprite != null) _planeRenderer.sprite = _loadedMap.planeSprite;
_camera.backgroundColor = _loadedMap.skyColor;
}
// O estilingue soltou: desliga a mira, liga o controle de voo.
private void OnLaunched()
{
_launch.enabled = false;
_flightInput.enabled = true;
CurrentPhase = Phase.Flying;
}
// Soma moedas da rodada.
private void OnCoinCollected(int value) => _coins += value;
// Pousou: paga a rodada e mostra o resultado.
private void OnLanded()
{
_flightInput.enabled = false;
CurrentPhase = Phase.Results;
float distance = Mathf.Max(0f, Distance);
_hud.Refresh(distance, _plane);
RoundResult result = Progress.RegisterRound(distance, _coins);
_results.Show(result, Progress.CurrentMap.goalDistance, Progress.IsLastMap);
}
}
}
7.4 Montar a cena
- Apague a instância de
Env_Map1da cena (agora quem cria é oRoundManager) e o objetoFlightTest. Crie um GameObject vazioEnvironmentem(0, 0). - Desligue o componente
FlightInputdo avião e oLaunchController(oRoundManagerliga cada um na hora certa). - Crie UI → Canvas:
Canvas ScaleremScale With Screen Size, referência 1080 × 1920,Match= 0,5.- Regra de tamanho do kit de UI: use tudo em 3× o tamanho original (3 × 360 px de largura útil ≈ a tela de 1080). Nas
Imagefatiadas (Image Type= Sliced), ponhaPixels Per Unit Multiplier= 0.33, para as bordas também ficarem 3× maiores e na mesma escala do resto. Hud(painel sem fundo):DistanceTextgrande no topo,GoalTextabaixo,WindTextno canto e o velocímetro:SpeedBar:Imagecombar_frame,Image Type= Simple, tamanho 963 × 99 (3 × 321 × 33).- Filho
Fill:Imagecombar_fill_white,Image Type= Filled,Fill Method= Horizontal,Fill Origin= Left. Âncoras esticadas nos dois eixos (preset "stretch" com Alt), comLeft/Right= 42 eTop/Bottom= 24: o trilho da moldura começa 14 px dos lados e 8 px de cima e de baixo, × 3. O fill é branco de propósito: oHudViewpinta de vermelho, amarelo ou verde. - Desmarque Raycast Target em tudo do HUD. Adicione
HudViewaoHude ligueSpeed FillaoFill.
Results:Imagecompanel, Sliced, multiplier 0.33, centralizado (~900 × 700). Dentro, três textos e o botão Continuar (Imagecombutton_blue, Sliced, multiplier 0.33, ~480 × 195). AdicioneResultsPanel. Use texto escuro (marrom ou preto) por cima do papel creme.
- Regra de tamanho do kit de UI: use tudo em 3× o tamanho original (3 × 360 px de largura útil ≈ a tela de 1080). Nas
- GameObject
RoundManagercom o componente; preencha todos os campos (Camera= Main Camera;Plane Renderer= oSpriteRendererdo avião). DeixeHangar Rootvazio por enquanto. - No botão Continuar:
On Click ()→ arraste oRoundManager→RoundManager.ShowHangar.
✅ Checkpoint da Fase 7
- Play: o avião aparece pronto no ponto de lançamento, com o HUD. Arraste e solte: o avião voa e o HUD mostra a distância subindo e o velocímetro mudando de cor.
- Puxando o nariz para cima, a barra fica amarela e depois vermelha, e o avião para de subir.
- Ao pousar: o painel de resultado mostra distância e dinheiro. "Continuar" volta à mira (sem hangar ainda) e os elementos do mapa reaparecem nos mesmos lugares.
- Parando e dando Play de novo, o recorde no HUD é o da rodada anterior (save funcionando).
Problemas comuns
- O avião ganha um impulso de vento logo ao ser lançado: o
FlightInputficou ligado no Inspector. Ele deve começar desligado. - Nada acontece ao arrastar: o
LaunchControllernão foi ligado — confira seHangar Rootestá vazio (aí oShowHangarchamaStartAiming) e se o campoLaunchdoRoundManagerestá preenchido. - Distância negativa: o
LaunchPointnão está em x = 0 ou foi movido; a distância é medida a partir dele.
Próxima fase: Hangar: Upgrades e Troca de Mapa — gastar o dinheiro e avançar de mapa.
Fase 8 — Hangar: Upgrades e Troca de Mapa
Objetivo desta fase: entre uma rodada e outra o jogador compra upgrades nas cinco trilhas e, ao bater a meta, avança de mapa com reset total.
8.1 UpgradeButton
// Caminho: Assets/_Project/Scripts/UI/UpgradeButton.cs
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using PaperFlight.Data;
using PaperFlight.Meta;
namespace PaperFlight.UI
{
// POR QUE: cada trilha (GDD §5.2) precisa de um botão com nome, nível e preço.
// ESTRATÉGIA: um prefab de botão reaproveitado 5 vezes, cada um apontando para
// um UpgradeDefinition. Ele escuta ProgressManager.Changed para se atualizar
// sempre que o dinheiro ou um nível muda.
public class UpgradeButton : MonoBehaviour
{
[SerializeField] private UpgradeDefinition _upgrade;
[SerializeField] private Button _button;
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _nameText;
[SerializeField] private TMP_Text _levelText;
[SerializeField] private TMP_Text _costText;
// Liga o clique à compra e põe o ícone.
private void Awake()
{
_button.onClick.AddListener(() => ProgressManager.Instance.TryBuy(_upgrade));
if (_icon != null && _upgrade.icon != null) _icon.sprite = _upgrade.icon;
}
// Começa a escutar mudanças e já se desenha.
private void OnEnable()
{
ProgressManager.Instance.Changed += Refresh;
Refresh();
}
// Para de escutar (evita erro quando o objeto é desligado).
private void OnDisable()
{
if (ProgressManager.Instance != null) ProgressManager.Instance.Changed -= Refresh;
}
// Atualiza textos e se o botão pode ser clicado.
private void Refresh()
{
ProgressManager progress = ProgressManager.Instance;
int level = progress.LevelOf(_upgrade.track);
_nameText.text = _upgrade.displayName;
_levelText.text = $"{_upgrade.LabelForLevel(level)} ({level}/{_upgrade.maxLevel})";
if (_upgrade.IsMaxed(level))
{
_costText.text = "MÁX";
_button.interactable = false;
}
else
{
_costText.text = $"$ {_upgrade.CostForNextLevel(level)}";
_button.interactable = progress.CanBuy(_upgrade);
}
}
}
}
8.2 HangarPanel
// Caminho: Assets/_Project/Scripts/UI/HangarPanel.cs
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using PaperFlight.Game;
using PaperFlight.Meta;
namespace PaperFlight.UI
{
// POR QUE: a tela entre rodadas: dinheiro, mapa, meta, botão de lançar e o
// botão de avançar de mapa, que só aparece depois da meta (GDD §6).
// ESTRATÉGIA: como avançar de mapa apaga todos os upgrades, o botão abre uma
// confirmação antes (placeholder do GDD §6).
public class HangarPanel : MonoBehaviour
{
[SerializeField] private RoundManager _round;
[SerializeField] private TMP_Text _moneyText;
[SerializeField] private TMP_Text _mapText;
[SerializeField] private TMP_Text _bestText;
[SerializeField] private Button _launchButton;
[SerializeField] private Button _nextMapButton;
[Header("Confirmação de troca de mapa")]
[SerializeField] private GameObject _confirmPanel;
[SerializeField] private Button _confirmYes;
[SerializeField] private Button _confirmNo;
// Liga os botões aos métodos certos.
private void Awake()
{
_launchButton.onClick.AddListener(_round.StartAiming);
_nextMapButton.onClick.AddListener(() => _confirmPanel.SetActive(true));
_confirmYes.onClick.AddListener(() =>
{
_confirmPanel.SetActive(false);
_round.AdvanceMap();
});
_confirmNo.onClick.AddListener(() => _confirmPanel.SetActive(false));
}
// Começa a escutar mudanças e se desenha.
private void OnEnable()
{
ProgressManager.Instance.Changed += Refresh;
_confirmPanel.SetActive(false);
Refresh();
}
// Para de escutar.
private void OnDisable()
{
if (ProgressManager.Instance != null) ProgressManager.Instance.Changed -= Refresh;
}
// Atualiza os textos e a visibilidade do botão de próximo mapa.
private void Refresh()
{
ProgressManager progress = ProgressManager.Instance;
GameState state = progress.State;
_moneyText.text = $"$ {state.money}";
_mapText.text = $"Mapa {state.mapIndex + 1}/{progress.Config.maps.Length}: {progress.CurrentMap.displayName}";
_bestText.text = $"Recorde {state.bestDistance:0} m / Meta {progress.CurrentMap.goalDistance:0} m";
_nextMapButton.gameObject.SetActive(progress.CanAdvanceMap);
}
}
}
8.3 Montar o hangar
Mesma regra de tamanho da Fase 7.4: kit de UI em 3×, Image fatiadas com Pixels Per Unit Multiplier = 0.33.
- No Canvas, crie
Hangar:Imagecompanel, Sliced, esticado na tela com ~40 px de margem. Dentro:MoneyText,MapText,BestText, uma coluna (Vertical Layout Group, espaçamento ~24) para os upgrades, o botão Lançar grande embaixo (button_blue, Sliced, ~900 × 195) e o botão Próximo mapa (button_yellow_square, Sliced, ~900 × 160 — o amarelo destaca a ação rara). - Crie o prefab
UpgradeButton: umButtoncomImage=button_blue(Sliced, ~900 × 200). Dentro:Icon:Imagecom Preserve Aspect marcado, 96 × 96 (3 × 32), à esquerda. OUpgradeButtonpõe nele oIcondoUpgradeDefinition(preenchido na Fase 1.5 comArt/Icons/upgrade_*).- Três textos (nome, nível, preço), em branco ou creme sobre o azul.
- O componente
UpgradeButtoncom os campos preenchidos. Coloque 5 instâncias na coluna, cada uma com seuUpgradeDefinition. O botão sem dinheiro fica acinzentado pelaDisabled ColordoButton(padrão da Unity).
- Dentro do
Hangar, umConfirmPanel(panel, Sliced, ~800 × 500) com o texto "Avançar de mapa reseta todos os upgrades. Continuar?" e os botões Sim (button_yellow_square) e Não (button_blue). - Adicione
HangarPanelaoHangare preencha os campos. NoRoundManager, preenchaHangar Rootcom o objetoHangar. - A ordem dos filhos no Canvas define quem fica por cima: deixe
HangareResultsabaixo doHudna Hierarchy.
✅ Checkpoint da Fase 8
- Play abre o hangar. Os botões de upgrade sem dinheiro ficam cinza.
- Use ⋮ → Debug: +1000 de dinheiro no
ProgressManager: os botões acendem; comprar Lançamento algumas vezes faz o próximo voo ir visivelmente mais longe. - Ao bater 300 m no mapa 1, o resultado avisa e o hangar mostra Próximo mapa. Confirmando, o mapa vira 2/5, o dinheiro e os níveis zeram, e a meta passa a 600 m.
Problemas comuns
NullReferenceExceptionemOnEnabledoUpgradeButton: oProgressManagerainda não existia. Confira se ele está na cena e com o[DefaultExecutionOrder(-100)].- Clicar em "Lançar" já lança o avião: não deve acontecer, porque o estilingue só começa num toque novo (
wasPressedThisFrame). Se acontecer, confira se oLaunchControllernão ficou ligado no Inspector.
Próxima fase: Arte dos 5 Mapas — cenário, chão, obstáculos e céu dos mapas 2 a 5, e a lufada de vento.
Fase 9 — Arte dos 5 Mapas
Objetivo desta fase: os mapas 2 a 5 com cenário, chão, obstáculos, avião e céu próprios, e a lufada visual do vento. O Mapa 1 já foi montado nas Fases 3 e 6; aqui é repetir o molde com a arte de cada mapa.
9.1 A arte gerada
Toda a arte está em Sprites/PaperFlight/ (gerada no PixelLab em 2026-10-06) e foi importada na Fase 0.5. O README.md daquela pasta tem os ids do PixelLab, as receitas e os scripts para regerar. A prévia Sprites/PaperFlight/_preview/maps_all.png mostra como cada mapa deve ficar.
| Asset | Arquivos em Art/ | Tamanho | Onde entra |
|---|---|---|---|
| Aviões (um por mapa, GDD §8) | Planes/plane_<n>_<mapa> | 32 × 32 | Map_N.planeSprite (Fase 1.5) |
| Fundos (2 camadas por mapa, já repetem sem emenda) | Backgrounds/<n>_<mapa>_near e _far | ~250 × 128 | Env_MapN (Fases 3.4 e 9.2) |
| Chão | Ground/<n>_<mapa>_surface e _fill | 16 × 16 | Env_MapN (Fases 3.4 e 9.2) |
| Obstáculos (1 de chão + 1 de ar por mapa) | Obstacles/<n>_<mapa>_ground_* e _air_* | 32 × 96 / 32 × 32 (pipa 32 × 48) | prefabs Obstacle_* (Fases 6.3 e 9.3) |
| Moeda, bandeira, corrente de ar, lufada | Common/coin, goal_flag, air_current, wind_puff | 16, 16 × 48, 48 × 128, 32 | Fases 6.3 e 9.4 |
| Ícones das 5 trilhas | Icons/upgrade_* | 32 × 32 | UpgradeDefinition.icon (Fase 1.5) |
| Kit de UI | ArtUI/panel, button_blue, button_yellow_square, bar_frame, bar_fill_white | alta resolução | HUD, resultado e hangar (Fases 7.4 e 8.3) |
| Céu | não é sprite | — | Map_N.skyColor (Fase 1.5) |
9.2 Cenário dos mapas 2 a 5
- No Project, selecione
Env_Map1e duplique (Ctrl+D) quatro vezes:Env_Map2aEnv_Map5. - Abra cada um (duplo clique) e troque os sprites e os números de
Surface,Fill,NeareFarconforme a tabela. Posições,Order in Layer,Factor X/Ye o collider doGroundficam iguais aos do Quintal.
| Prefab | Surface / Fill | Near: sprite, Tile Width, Size X | Far: sprite, Tile Width, Size X |
|---|---|---|---|
Env_Map1 | 1_quintal_surface / 1_quintal_fill | 1_quintal_near, 15.9375, 47.8125 | 1_quintal_far, 15.9375, 47.8125 |
Env_Map2 | 2_parque_surface / 2_parque_fill | 2_parque_near, 16, 48 | 2_parque_far, 15.75, 47.25 |
Env_Map3 | 3_praia_surface / 3_praia_fill | 3_praia_near, 15.75, 47.25 | 3_praia_far, 13.375, 40.125 |
Env_Map4 | 4_cidade_surface / 4_cidade_fill | 4_cidade_near, 16, 48 | 4_cidade_far, 15.625, 46.875 |
Env_Map5 | 5_montanha_surface / 5_montanha_fill | 5_montanha_near, 15.75, 47.25 | 5_montanha_far, 15.4375, 46.3125 |
- Em cada
Map_N, ponha oEnv_MapNcerto emEnvironment Prefab. Avião e céu já foram preenchidos na Fase 1.5.
9.3 Obstáculos dos mapas 2 a 5
- Duplique
Obstacle_BirdhouseeObstacle_Sparrowpara cada mapa e troque o sprite. Depois de trocar, remova e adicione de novo oPolygonCollider2D, para ele recalcular o contorno do desenho novo.
| Mapa | Obstáculo de chão (sprite) | Obstáculo de ar (sprite) |
|---|---|---|
| 1 Quintal | Obstacle_Birdhouse (1_quintal_ground_birdhouse) | Obstacle_Sparrow (1_quintal_air_sparrow) |
| 2 Parque | Obstacle_ParkTree (2_parque_ground_tree) | Obstacle_Kite (2_parque_air_kite) |
| 3 Praia | Obstacle_Palm (3_praia_ground_palm) | Obstacle_Seagull (3_praia_air_seagull) |
| 4 Cidade | Obstacle_Lamppost (4_cidade_ground_lamppost) | Obstacle_Drone (4_cidade_air_drone) |
| 5 Montanha | Obstacle_Pine (5_montanha_ground_pine) | Obstacle_Eagle (5_montanha_air_eagle) |
- Em cada
Map_N:Goal Marker Prefab=GoalFlageSpawnscom o mesmo molde do Mapa 1 (Fase 6.3):Coin(peso 3; altura 2–15),AirCurrent_Up(1; 4–4), o obstáculo de chão do mapa (1; 0–0) e o de ar (0,5; 6–18). A dificuldade de cada mapa (pesos, alturas, distância entre elementos) está Em aberto no GDD §7; mude só os números doMapDefinition.
9.4 Lufada do vento: WindPuffEffect
// Caminho: Assets/_Project/Scripts/UI/WindPuffEffect.cs
using UnityEngine;
using PaperFlight.Flight;
namespace PaperFlight.UI
{
// POR QUE: o toque de vento precisa de resposta visual imediata, senão o
// jogador não sabe se gastou a carga.
// ESTRATÉGIA: escuta o evento WindUsed do avião e toca um ParticleSystem
// (ou animação) preso atrás dele. Fica separado do PaperPlane para a física
// não depender de efeito visual.
public class WindPuffEffect : MonoBehaviour
{
[SerializeField] private PaperPlane _plane;
[SerializeField] private ParticleSystem _puff;
// Começa a escutar o avião.
private void OnEnable() => _plane.WindUsed += Play;
// Para de escutar.
private void OnDisable() => _plane.WindUsed -= Play;
// Solta a lufada.
private void Play() => _puff.Play();
}
}
Monte o efeito assim:
- Filho do
PaperPlane: Effects → Particle System, nomeWindPuff, posição(-1, 0)(atrás da cauda). - Módulo principal:
LoopingePlay On Awakedesmarcados,Start Lifetime= 0.4,Start Speed= 0,Start Size= 2 (o sprite tem 32 px = 2 unidades),Simulation Space= World (a lufada fica para trás enquanto o avião segue),Max Particles= 5. - Emission:
Rate over Time= 0 e um Burst comCount= 1 no tempo 0. - Shape: desmarcado (a lufada nasce exatamente no ponto).
- Color over Lifetime: de branco opaco para branco transparente, para a lufada sumir aos poucos.
- Texture Sheet Animation:
Mode= Sprites, e adicioneCommon/wind_puffna lista. - Renderer:
Material=Sprite-Lit-Default,Order in Layer= 9. - Adicione
WindPuffEffectao avião, comPlane= o próprio avião ePuff=WindPuff.
9.5 Ainda falta
- Animações (pássaros batendo asas, pipa, hélices do drone, moeda girando, corrente subindo, lufada abrindo). No MVP os sprites ficam parados. Quando existirem, cada uma vira um
Animatorno prefab; o código não muda. - Sprites de ângulo do avião, se a rotação livre ficar serrilhada (GDD §8, Em aberto).
- Quem lança o avião (GDD §3, Em aberto).
✅ Checkpoint da Fase 9
- Avançando de mapa (use ⋮ → Debug: +1000 de dinheiro e bata a meta), cada mapa mostra o seu cenário, chão, avião, obstáculos e cor de céu, igual à prévia
Sprites/PaperFlight/_preview/maps_all.png. - Em todos os mapas, o fundo repete sem emenda num voo longo e os obstáculos de chão ficam apoiados na grama, na areia, na calçada ou na neve.
- Cada toque de vento solta uma lufada que fica para trás e some.
Problemas comuns
- Obstáculo flutuando ou enterrado: o pivô do sprite não foi ajustado (tabela da Fase 0.5).
- O avião bate "no ar" perto de um obstáculo novo: o
PolygonCollider2Dficou com o contorno do sprite antigo. Remova e adicione o collider de novo. - A lufada não aparece: falta o Burst (com
Rate over Time= 0 nada nasce) ou o material do Renderer está vazio (a partícula fica rosa). - O avião fica "serrilhado" ao girar: é o efeito da rotação livre de pixel art (GDD §8, Em aberto). A alternativa é gerar de 8 a 16 sprites de ângulo e trocar o sprite conforme o
Pitch, em vez de girar.
Próxima fase: Build e Próximos Passos — rodar no celular.
Fase 10 — Build e Próximos Passos
Objetivo desta fase: o MVP roda num celular Android (e iOS, se houver Mac), em retrato, com save funcionando.
10.1 Android
- File → Build Profiles → Android → Switch Platform.
- Player Settings → Other Settings:
Scripting Backend= IL2CPP,Target Architectures= ARM64 (exigido pela Play Store).Package Name=com.manguebyte.paperflight(sugestão; confirme o padrão do estúdio). - Resolution and Presentation:
Default Orientation= Portrait (já feito na Fase 0). - Ative o modo desenvolvedor e a depuração USB no celular, conecte e use Build And Run.
10.2 iOS (opcional no MVP)
- Precisa de um Mac com Xcode. Build Profiles → iOS → Switch Platform → Build gera um projeto Xcode; abra, escolha a equipe de assinatura e rode no aparelho.
10.3 PC/Web para testes com mouse
- Um build Windows ou Web serve para testar com mouse sem celular. Se isso vira plataforma de lançamento está Em aberto (GDD §1).
✅ Checkpoint da Fase 10
- O jogo abre no celular em retrato, o estilingue e o voo respondem ao toque, e o toque curto dá vento sem atrapalhar o deslize.
- Fechando o app pelo multitarefa e abrindo de novo, dinheiro, níveis e mapa continuam lá.
- Rodando a 60 fps num aparelho intermediário durante um voo longo.
Problemas comuns
- Toque e deslize se confundem no celular: ajuste
_tapMaxMovee_tapMaxDurationdoFlightInput. Telas pequenas costumam pedir uma tolerância de movimento maior. - O jogo roda a 30 fps no Android: a Unity limita a 30 por padrão no mobile. Ponha
Application.targetFrameRate = 60;noAwakedoProgressManagerou de um script de boot.
10.4 Próximos passos
Decisões Em aberto que mais mexem no código (ver GDD §13 para a lista completa):
- Efeito dos obstáculos (frear vs. derrubar) — muda
ObstacleePaperPlane.HitObstacle. - O que reseta ao trocar de mapa e o que vem depois do mapa 5 — muda
GameState.ResetForMapeProgressManager.AdvanceMap. - Bônus permanente / prestígio — exigiria um campo novo no
GameStatequeResetForMapnão apaga. - Mapas feitos à mão vs. semente fixa — trocaria o
MapSpawnerpor elementos posicionados na cena de cada mapa. - Rotação do sprite do avião (livre vs. sprites de ângulo).
- Leaderboard pós-MVP, seguindo o padrão Firebase do estúdio se confirmado.
- Balanceamento de todos os placeholders da Parte 1 §12 com playtest.
Versão: rascunho inicial — a refinar conforme prototipagem.