Pular para o conteúdo principal

Orbitfall Iso — Guia Unity

10. Guia de Implementação Unity (versão 1.0)​

Este guia leva o jogo do projeto vazio até a publicação. Ele continua didático: cada fase explica os conceitos novos antes de usar, e termina com algo que dá para ver funcionando. Mas o código é de produto final: dados em ScriptableObjects, object pooling, save versionado, sistemas desacoplados e tudo pronto para a Web.

De onde vem este guia. Ele é uma cópia adaptada do guia do 8-Bit Armageddon, porque as regras são as mesmas. As diferenças estão onde a câmera isométrica e a falta de anúncios pedem: a Fase 2 ganha a projeção (IsoProjection, IsoView, IsoFloor, IsoGrid), as Fases 3, 4, 5 e 7 desenham na vista projetada, e a Fase 8 não tem Revive. O projeto Unity é outro, separado do 8-Bit Armageddon.

Cada fase segue o mesmo formato:

  • Conceitos novos: o que você vai aprender e por quê.
  • Passos: o que fazer no Editor e o código completo de cada script.
  • ✅ Checkpoint: como confirmar que funcionou.
  • Problemas comuns: os erros mais prováveis e como resolver.

10.1 Convenções do projeto​

Idioma: todo nome no projeto (scripts, classes, variáveis, funções, GameObjects, Prefabs, assets, pastas) é em inglês e usa os termos do glossário. Se o glossário diz Satellite, nada no projeto se chama Turret, Drone ou Orbital. Comentários no código podem ser em português.

Código C#:

ElementoPadrãoExemplo
NamespaceArmageddon.<Área>Armageddon.Combat
Classe, struct, enum, interfacePascalCase; interfaces com IWaveDirector, ITarget
Método e propriedadePascalCaseTryPurchase(), CurrentWave
Campo privado_camelCase_orbitSpeed
Campo exposto no Inspector[SerializeField] private + _camelCase[SerializeField] private float _orbitRadius;
Evento C#PascalCase, verbo no passado, sem Onevent Action<Enemy> EnemyKilled;
Método que responde a eventoHandle + nome do eventoHandleEnemyKilled(Enemy enemy)
ConstantePascalCaseconst float OrbitRadius = 1.75f;

Por que _camelCase + [SerializeField] private em vez de public? Um campo public pode ser alterado por qualquer outro script, e bugs de "quem mudou esse valor?" são dos mais difíceis de achar. Com [SerializeField] private, o valor aparece no Inspector para você ajustar, mas só a própria classe pode alterá-lo em código.

Assets:

TipoPadrãoExemplo
PrefabPascalCase, nome do conceitoGrunt.prefab, Satellite.prefab, StatCard.prefab
ScriptableObject de dados<Tipo>_<Nome>Enemy_Grunt, Stat_Damage, Core_Terra, Achievement_FirstContact
Sprite / spritesheet<Tipo>_<Nome>SPR_Planet_Terra_Classic, SPR_Enemy_Scout
ÁudioSFX_<Nome> / MUS_<Nome>SFX_SatelliteFire, MUS_Gameplay
CenaPascalCaseBoot, MainMenu, Gameplay

Hierarquia das cenas: cada cena tem GameObjects vazios de agrupamento, na raiz, com nomes entre colchetes: [Systems] (gerenciadores sem visual), [World] (tudo que aparece no mundo do jogo), [UI] (Canvas e telas). Nada solto na raiz além deles, da câmera e do EventSystem.

Chão e vista: todo objeto do mundo tem a raiz nas coordenadas do chão (onde a regra acontece, Seção 4) e o desenho num filho View, que o IsoView coloca na posição projetada (Fase 2). Os scripts de regra só mexem na raiz. Coisas deitadas no chão (sombras, Quadrants) ficam dentro de [World]/IsoFloor/Plane.

10.2 Mapa de sistemas​

Todos os scripts principais do jogo, com a fase em que cada um é criado. Use esta tabela como índice: se você se perguntar "onde fica a lógica de X?", a resposta está aqui.

Área (namespace)ScriptResponsabilidadeFase
Armageddon.CoreGameBootstrapPrimeiro código a rodar, antes de qualquer cena: cria o [Services] e registra os serviços1
Armageddon.CoreBootSequenceNa cena Boot: mostra o logo e carrega o Main Menu1
Armageddon.CoreServicesAcesso central aos serviços (save, cenas, pausa…)1
Armageddon.CoreSceneLoaderTroca de cenas com tela de transição1
Armageddon.CoreSaveService, PlayerProfile, SaveStorageSave JSON versionado, os dados salvos do jogador e onde eles moram (arquivo ou navegador)1
Armageddon.CoreGameClockPausa (manual e automática) e escala de tempo1
Armageddon.CoreAppLifecyclePerda de foco da aba, fechar o jogo e a tecla Esc1
Armageddon.CoreDevShortcutsAtalhos de teste (só no Editor e em builds de desenvolvimento)1
Armageddon.WorldWorldLayoutMedidas do mundo (raio do planeta, órbita, spawn) e a grade de pixels2
Armageddon.WorldQuadrant, QuadrantsOs 4 Quadrants e em qual deles uma posição ou ângulo está2
Armageddon.WorldIsoProjectionA conta chão → vista (e de volta), num lugar só2
Armageddon.WorldSpriteAnimatorLoop de frames sem Animator (planeta, estrelas, inimigos, VFX)2
Armageddon.WorldIsoView, ShadowSpriteColoca o desenho de um objeto na posição projetada, com sombra no grid2
Armageddon.WorldSpaceBackgroundFundo em tiles sorteados e estrelas piscando2
Armageddon.WorldIsoGridO grid isométrico (só visual)2
Armageddon.WorldIsoFloorO plano deitado onde ficam os Quadrants e os eixos2
Armageddon.WorldCameraRigPixel Perfect Camera e deslocamento com a gaveta (o shake entra na Fase 12)2
Armageddon.PlanetsPlanet, PlanetHealthO planeta, HP, dano recebido, regeneração2
Armageddon.CombatSatellite, SatelliteOrbitDisparo; a órbita compartilhada e o espaçamento dos Satellites3
Armageddon.CombatTargetSelector, TargetPriorityEscolha de alvo dentro do Quadrant e do Attack Range3
Armageddon.CombatITarget, TargetRegistryO contrato de "alvo" e a lista dos alvos vivos3
Armageddon.CombatSatelliteStatsOs Stats de Offense dos Satellites e a fórmula de dano3
Armageddon.CombatProjectile, ProjectilePoolProjétil teleguiado reaproveitado por pooling3
Armageddon.WorldPixelSpriteSprite branco de 1 pixel, criado por código3
Armageddon.CombatQuadrantViewMostra no grid o Quadrant coberto por cada Satellite3
Armageddon.CombatCombatDevTools, TargetDummyTeclas e alvos de teste do combate (só no Editor e em builds de desenvolvimento)3
Armageddon.EnemiesEnemyDefinitionScriptableObject com os dados de cada inimigo4
Armageddon.EnemiesEnemy, EnemyPoolInimigo em cena (implementa ITarget e entra no TargetRegistry), pooling e os eventos de morte4
Armageddon.WorldExplosionPoolExplosões (animação "uma vez") reaproveitadas por pooling4
Armageddon.EnemiesEnemyDevToolsTeclas de teste de inimigos (só no Editor e em builds de desenvolvimento)4
Armageddon.EnemiesIEnemyMovement, StraightMovement, ZigZagMovementComportamentos de movimento4
Armageddon.WavesWaveDirector, WaveBalanceWaves híbridas, composição, escalada, fila de spawn, Wave Clear5
Armageddon.WavesSpawnSectorIndicatorAviso na borda da tela5
Armageddon.WavesWaveDevToolsTeclas de teste das Waves (só no Editor e em builds de desenvolvimento)5
Armageddon.EconomyStatDefinition, StatCatalogDefinição dos 13 Stats e a ordem deles na gaveta6
Armageddon.EconomyRunStats, StatModifierNível e valor de cada Stat na run; modificadores do Planet Core6
Armageddon.EconomyShardWallet, UpgradeServiceSaldo de Shards e compra de Upgrades6
Armageddon.EconomySatelliteShopCompra dos Satellites 2, 3 e 4 com Shards6
Armageddon.EconomyRunEconomyCria a economia da run, transforma kills e Wave Clears em Shards e aplica os Stats6
Armageddon.EconomyEconomyDevToolsTeclas de teste da economia (só no Editor e em builds de desenvolvimento)6
Armageddon.UIIntegerCanvasScaleEscala inteira da UI, igual à da Pixel Perfect Camera6
Armageddon.UIUpgradeDrawer, StatCard, SatellitePanelA gaveta de upgrades6
Armageddon.EnemiesMothership, LaserAttackO boss (entrada e espiral) e o laser telegrafado7
Armageddon.EnemiesMothershipSpawnerCria a Mothership nas Boss Waves e a conta como inimiga viva7
Armageddon.UIBossHealthBarBarra de HP da Mothership no topo da tela7
Armageddon.EnemiesMothershipDevToolsTecla de teste da Mothership (só no Editor e em builds de desenvolvimento)7
Armageddon.RunRunController, RunSummaryCiclo de vida da run e fim de run8
Armageddon.RunRunDevToolsTeclas de teste da run (só no Editor e em builds de desenvolvimento)8
Armageddon.UIHudView, PauseView, ResultsViewTelas da run8
Armageddon.PlanetsEnergyShield, EnergyShieldViewCargas do Energy Shield, recarga e a bolha8A
Armageddon.CombatReflector, ReflectorArcViewO Reflect Shield: órbita, Reflect Arc, tiros devolvidos8A
Armageddon.EnemiesEnemyBolt, BoltPoolO Gunner Bolt e o pool dele8A
Armageddon.EconomyStatApplierCopia os Stats para Satellites, planeta e escudos (nos dois modos)8A
Armageddon.RunIRunFlowO que a tela de pausa precisa da run, nos dois modos8B
Armageddon.InvasionAiProfile, InvasionBalance, FleetUpgradesDados e números do Invasion, Fleet Upgrades8B
Armageddon.InvasionPlanetAI, SiegeController, InvasionSummaryA IA que compra para o planeta e o ciclo do cerco8B
Armageddon.InvasionFleetLauncher, SectorPicker, InvasionDevToolsLançar naves, escolher o Spawn Sector, teclas de teste8B
Armageddon.UIInvasionDrawer, ShipCard, FleetCard, InvasionHudView, IntelPanel, InvasionResultsViewTelas do Invasion8B
Armageddon.MetaPlanetCoreDefinition, SkinDefinitionPlanet Cores e Skins9
Armageddon.MetaAchievementDefinition, AchievementServiceConquistas9
Armageddon.UIMainMenuView, CoreSelectView, AchievementsViewTelas do menu9
Armageddon.CoreSettingsServiceConfigurações e acessibilidade11
Armageddon.UISettingsViewTela de Settings11
Armageddon.JuiceAudioService, CameraShaker, DamageNumberSpawner, HitFlashSom, shake, números de dano, flashes12
Armageddon.OnboardingHintService, HintDefinitionDicas contextuais13

10.3 Fases​

FaseConteúdoStatus
0Setup do projeto✅ Escrita
1Arquitetura: bootstrap, serviços, cenas, save, pausa✅ Escrita
2Planeta, Quadrants e câmera✅ Escrita
3Satellites, órbita, Target Priority e projéteis✅ Escrita
4Inimigos✅ Escrita
5Waves✅ Escrita
6Stats, Shards, Upgrades e gaveta✅ Escrita
7Mothership✅ Escrita
8Run: pausa, HUD, Results✅ Escrita
8AGunner, Multishot, Energy Shield e Reflect Shield✅ Escrita
8BModo Invasion✅ Escrita
9Progressão entre runs: Planet Cores, Skins, conquistas, recorde de Wave e Main MenuA escrever
10(não existe neste jogo: sem anúncios, consentimento nem analytics, Seção 7. O número fica vago para as referências "Fase 11", "Fase 12"… baterem com o guia do 8-Bit Armageddon)—
11Localização, Settings e acessibilidadeA escrever
12Juice e áudioA escrever
13OnboardingA escrever
14Performance e build WebA escrever
15LançamentoA escrever

Fase 0 — Setup do Projeto​

Conceitos novos:

  • Unity Hub e módulos: o Unity Hub instala versões da Unity e os "módulos" de cada plataforma. Sem o módulo Web, a Unity não gera o build de navegador, que é a única plataforma deste jogo.
  • URP (Universal Render Pipeline): o sistema de renderização moderna da Unity. O template "Universal 2D" já vem com o renderizador 2D configurado, que é o que usamos para luzes e efeitos em pixel art.
  • Pacote (package): uma biblioteca oficial da Unity (Input System, Localization…) instalada pelo Package Manager. Bibliotecas de terceiros (DOTween) vêm da Asset Store.
  • Assembly Definition (asmdef): um arquivo que agrupa scripts numa "assembly" separada. Com ele, a Unity recompila só o que mudou, e o projeto fica mais rápido de iterar à medida que cresce. Também deixa explícito de quais bibliotecas o seu código depende.
  • Preset: uma configuração salva (ex.: "como importar um sprite de pixel art") que a Unity aplica automaticamente em todo arquivo novo de uma pasta. Evita o erro clássico de pixel art borrada.
  • Git LFS: extensão do Git para arquivos binários grandes (imagens, sons). Sem ela, o repositório incha a cada versão de um sprite.

Passo 1 — Instalar a Unity​

  1. Instale o Unity Hub (unity.com/download).
  2. No Hub, aba Installs → Install Editor → escolha a versão Unity 6 LTS mais recente (versão 6000.x, marcada como LTS).
  3. Na tela de módulos, marque:
    • Web Build Support;
    • Microsoft Visual Studio Community (ou use o JetBrains Rider / VS Code, se preferir).

Por que LTS? "Long Term Support" recebe só correções de bugs por dois anos, sem mudanças que quebrem o projeto. Para um jogo que vai ser publicado e atualizado, estabilidade vale mais que recurso novo.

Passo 2 — Criar o projeto e o repositório​

  1. No Hub, Projects → New project → template Universal 2D.

    • Project name: EightBitArmageddonIso (nomes de projeto que começam com número causam problemas em algumas ferramentas).
    • Location: uma pasta fora deste repositório de GDD. O projeto Unity é outro repositório, e também não é o do 8-Bit Armageddon: os dois jogos são separados.
  2. Instale o Git e o Git LFS (git-lfs.com). Uma vez só na máquina: git lfs install.

  3. Na pasta raiz do projeto (a que contém Assets/, Packages/, ProjectSettings/), rode git init.

  4. Crie o arquivo .gitignore com o conteúdo do modelo oficial para Unity (github.com/github/gitignore → Unity.gitignore). Ele ignora as pastas geradas Library/, Temp/, Logs/, Obj/, Build/ e UserSettings/.

  5. Crie o arquivo .gitattributes para mandar os binários para o LFS:

    # Imagens
    *.png filter=lfs diff=lfs merge=lfs -text
    *.psd filter=lfs diff=lfs merge=lfs -text
    *.aseprite filter=lfs diff=lfs merge=lfs -text
    # Áudio
    *.wav filter=lfs diff=lfs merge=lfs -text
    *.ogg filter=lfs diff=lfs merge=lfs -text
    *.mp3 filter=lfs diff=lfs merge=lfs -text
    # Fontes
    *.ttf filter=lfs diff=lfs merge=lfs -text
    *.otf filter=lfs diff=lfs merge=lfs -text
    # Arquivos da Unity que são texto (YAML): nunca LFS
    *.unity text
    *.prefab text
    *.asset text
    *.meta text
  6. Na Unity: Edit > Project Settings > Editor e confirme:

    • Version Control > Mode: Visible Meta Files;
    • Asset Serialization > Mode: Force Text.

    Os dois já são o padrão no Unity 6, mas confira: com arquivos .meta escondidos ou cenas em binário, o Git não consegue versionar o projeto direito.

  7. Primeiro commit: git add . e git commit -m "Initial Unity project".

Passo 3 — Instalar os pacotes​

Abra Window > Package Manager, selecione Unity Registry na lateral e instale:

PacotePara quêFase em que é usado
Input SystemMouse e teclado. Normalmente já vem instalado no Unity 6.1
LocalizationTextos em Inglês e Português11 (mas a configuração base entra na Fase 1)

O TextMeshPro já vem dentro do pacote Unity UI no Unity 6. Na primeira vez que você criar um texto TMP, a Unity pede para importar os "TMP Essentials": aceite.

Instale o DOTween (animações de UI e juice):

  1. Abra a Asset Store no navegador e adicione "DOTween (HOTween v2)", da Demigiant (gratuito), à sua conta.
  2. Na Unity: Window > Package Manager → My Assets → DOTween → Download → Import.
  3. Quando o painel do DOTween abrir (ou pelo menu Tools > Demigiant > DOTween Utility Panel), clique em Setup DOTween…, marque Create ASMDEF e clique em Apply. Isso cria a assembly DOTween.Modules, que o nosso código vai referenciar.

Este jogo não usa SDK de anúncio, de consentimento nem de analytics (Seção 7).

Passo 4 — Estrutura de pastas​

Todo o conteúdo do jogo fica dentro de Assets/_Project/. O _ coloca a pasta no topo da lista, e fica claro o que é nosso e o que veio de pacotes e plugins (DOTween, por exemplo, se instala em Assets/Plugins/).

Crie esta estrutura (botão direito → Create → Folder):

Assets/
_Project/
Art/
Planet/
Satellites/
Enemies/
Projectiles/
VFX/
UI/
Backgrounds/
Grid/
Fonts/
Audio/
Music/
SFX/
Mixers/
Data/
Balance/
Enemies/
Stats/
PlanetCores/
Skins/
Achievements/
Hints/
Localization/
Prefabs/
Gameplay/
UI/
VFX/
Scenes/
Scripts/
Core/
World/
Planet/
Combat/
Enemies/
Waves/
Economy/
Run/
Meta/
Platform/
UI/
Juice/
Onboarding/
Settings/
Input/
Presets/
Rendering/

Mova para Assets/_Project/Settings/Rendering/ os assets de URP que o template criou na pasta Assets/Settings/ (arraste pela janela Project da Unity, nunca pelo Explorer do Windows, senão as referências se perdem). Depois apague a pasta Assets/Settings/ vazia.

Passo 5 — Assembly Definition​

  1. Em Assets/_Project/Scripts/, botão direito → Create → Scripting → Assembly Definition. Nome: Armageddon.
  2. Selecione o arquivo e, no Inspector, em Assembly Definition References, adicione:
    • Unity.InputSystem
    • Unity.TextMeshPro
    • Unity.Localization
    • Unity.RenderPipelines.Universal.Runtime
    • DOTween.Modules
  3. Clique em Apply.

Todos os scripts dentro de Scripts/ (e das subpastas) passam a fazer parte da assembly Armageddon.

Nas próximas fases, se aparecer o erro The type or namespace name 'X' could not be found para uma biblioteca instalada, quase sempre falta adicionar a assembly dela aqui. A fase que introduz a biblioteca vai dizer qual referência adicionar.

Passo 6 — Preset de importação de pixel art​

  1. Arraste qualquer PNG de teste para dentro de Assets/_Project/Art/.
  2. Selecione o PNG e configure no Inspector:
    • Texture Type: Sprite (2D and UI)
    • Pixels Per Unit: 16
    • Filter Mode: Point (no filter)
    • Compression: None
    • Generate Mip Maps (em Advanced): desmarcado
  3. Clique em Apply.
  4. No topo do Inspector, clique no ícone de Preset (o controle deslizante ao lado do ícone de ajuda) → Save current to… → salve como Assets/_Project/Settings/Presets/SpriteImporter_PixelArt.preset.
  5. Abra Edit > Project Settings > Preset Manager → Add Default Preset → Importer > TextureImporter → escolha SpriteImporter_PixelArt. No campo Filter, escreva:
    glob:"Assets/_Project/Art/**"
  6. Apague o PNG de teste.

A partir de agora, toda imagem colocada em Art/ já entra com as configurações certas de pixel art.

Por que PPU 16? Com 16 pixels por unidade, a tela de referência 320×180 mostra exatamente 20 × 11,25 unidades da vista. Uma célula de sprite de 32×32 (Seção 6.2) ocupa 2 × 2 u, o tamanho do planeta. As distâncias do GDD (Seção 4) são medidas no chão; a Fase 2 explica como o chão vira vista.

Passo 7 — Importar a arte​

A arte do jogo já está pronta no repositório do GDD, em Sprites/8-Bit-Armageddon/ (a herdada) e Sprites/8-Bit-Armageddon-Iso/ (o grid), e cada arquivo está descrito na Seção 6.4. Neste passo ela entra no projeto já configurada. Nenhum sprite será usado em cena ainda: isso começa na Fase 2.

Conceitos novos:

  • Sprite Mode Multiple: um PNG com vários desenhos (uma spritesheet) vira vários sprites, um por célula. Sem isso, a Unity trata o arquivo inteiro como uma imagem só.
  • Slice (fatiar): o Sprite Editor corta a spritesheet em células do mesmo tamanho. No nosso caso, 32×32 (ou 64×64 na Mothership), como definido na Seção 6.2.
  • 9-slice: um sprite com as bordas marcadas. Ao esticar, a Unity estica só o meio e mantém os cantos intactos: é assim que um painel de 32×32 vira uma janela de qualquer tamanho.
  • Sorting Layer: a "camada" de desenho de um sprite. Quem está numa camada mais de baixo na lista é desenhado por cima.
  1. Copie a arte. Copie para Assets/_Project/Art/ as pastas Planet, Satellites, Enemies, Projectiles, VFX, UI, Backgrounds e Fonts de Sprites/8-Bit-Armageddon/. Não copie _candidatos_descartados/, _tools/, _previews/, _referencia/, os _preview_*.gif nem o PROMPTS.md: são material de produção, não do jogo. No PowerShell, um comando faz tudo (troque os dois caminhos pelos seus):

    robocopy "C:\caminho\GDD\Sprites\8-Bit-Armageddon" "C:\caminho\ArmageddonIso\Assets\_Project\Art" /E /XD _candidatos_descartados _tools _previews _referencia /XF PROMPTS.md _preview_*
    robocopy "C:\caminho\GDD\Sprites\8-Bit-Armageddon-Iso" "C:\caminho\ArmageddonIso\Assets\_Project\Art" /E /XD _candidatos_descartados

    O segundo comando traz a pasta Grid/, com o SPR_Grid_Floor.png. Copiar arquivos novos pelo Explorer é seguro; o que quebra referências é mover arquivos que a Unity já conhece. Volte para a Unity e espere a importação terminar. O preset do Passo 6 já aplica PPU 16 e Filter Point em tudo.

  2. Marque as spritesheets como Multiple. Na janela Project, selecione todos os PNGs exceto os que têm uma célula só (SPR_Planet_<Core>_<Skin>.png, os 9 estáticos, mais SPR_UI_Panel.png e SPR_UI_DrawerStrip.png). Segure Ctrl para selecionar vários. No Inspector, troque Sprite Mode para Multiple e clique Apply.

  3. Fatie cada spritesheet. Para cada arquivo marcado no item 2: selecione → Open Sprite Editor → menu Slice → Type: Grid By Cell Size → Pixel Size: 32 × 32 (use 64 × 64 em SPR_Enemy_Mothership e SPR_VFX_Explosion_Mothership) → Pivot: Center → Method: Delete Existing → Slice → Apply (canto de cima à direita). Deixe Keep Empty Rects desmarcado: as 2 últimas células de SPR_UI_Icons estão vazias e não viram sprite.

  4. Marque as bordas de 9-slice. Abra o Sprite Editor de SPR_UI_Panel e de SPR_UI_Button. Clique em cada sprite (o painel, e os 3 estados do botão) e, no quadro Sprite do canto de baixo, preencha Border L 8, T 8, R 8, B 8. Apply.

  5. Crie o Font Asset da fonte. Window > TextMeshPro > Font Asset Creator e configure:

    • Source Font File: Art/Fonts/m5x7.ttf
    • Sampling Point Size: Custom Size = 16
    • Padding: 0
    • Render Mode: RASTER_HINTED (fonte pixel não usa SDF; com SDF ela fica borrada)
    • Character Set: Extended ASCII (inclui todos os acentos do português)
    • Atlas Resolution: 256 × 256

    Clique Generate Font Atlas → Save e salve em Art/Fonts/ como m5x7 Raster.asset.

  6. Crie as Sorting Layers. Edit > Project Settings > Tags and Layers → Sorting Layers → + e crie, nesta ordem, abaixo de Default: Background, Grid, Floor, World, VFX. A ordem da lista é a ordem de desenho (Seção 6.4): a última fica por cima. Repare que não há uma camada para inimigos e outra para o planeta, como num jogo top-down: na isométrica, quem fica na frente depende da posição (Seção 6.1), então planeta, inimigos, Satellites e projéteis dividem a camada World e são ordenados pelo Y (Fase 2).

Passo 8 — Cenas​

Em Assets/_Project/Scenes/, crie três cenas (botão direito → Create → Scene, ou File > New Scene e salve):

CenaPara quê
BootPrimeira cena. Inicializa os serviços e passa para o Main Menu. Nunca é recarregada.
MainMenuMenu, Planet Cores, conquistas, recorde de Wave, Settings
GameplayA run

Apague a cena de exemplo que veio com o template (SampleScene).

Abra File > Build Profiles (no Unity 6, substitui o antigo "Build Settings"), clique em Scene List e adicione as cenas nesta ordem: Boot (índice 0), MainMenu, Gameplay. A cena de índice 0 é a que abre quando o jogo inicia.

Passo 9 — Player Settings (todas as plataformas)​

Edit > Project Settings > Player, na parte comum a todas as plataformas:

CampoValor
Company NameMangueByteGames
Product Name8-Bit Armageddon Iso (este é o nome que o jogador vê, então pode ter hífen e espaço)
Version0.1.0

Em Other Settings > Configuration:

  • Active Input Handling: Input System Package (New). Se a Unity pedir para reiniciar, reinicie.

Passo 10 — Plataforma Web​

  1. File > Build Profiles → selecione Web → Switch Platform. É a única plataforma do jogo (Seção 7.5). A troca demora um pouco na primeira vez, porque a Unity reimporta os assets.
  2. Edit > Project Settings > Player, aba Web:
    • Resolution and Presentation → Default Canvas Width / Height: 1280 × 720 (16:9, múltiplo inteiro de 320×180).
    • Publishing Settings → Compression Format: Brotli
    • Publishing Settings → Decompression Fallback: marcado. Sem isso, o jogo não abre em servidores que não enviam os cabeçalhos HTTP de compressão (como o itch.io). A Fase 14 detalha a configuração do site próprio.

Passo 11 — Commit​

Feche a Unity (ela só grava algumas configurações em disco ao fechar), abra de novo, e faça o commit: git add . e git commit -m "Project setup: packages, folders, presets, art import, scenes, web settings".

✅ Checkpoint:

  • O projeto abre sem nenhuma mensagem vermelha no Console (Window > General > Console).
  • Assets/_Project/ tem toda a estrutura de pastas, e Scripts/ tem o Armageddon.asmdef.
  • Um PNG novo colocado em Art/ entra automaticamente com PPU 16 e Filter Point.
  • A cena Boot está no índice 0 da Scene List.
  • Em Art/Enemies/, clicar na setinha de SPR_Enemy_Grunt mostra 8 sprites (SPR_Enemy_Grunt_0 a _7), e a de SPR_Enemy_Mothership mostra 5. Arrastar qualquer um deles para a cena mostra o desenho nítido, do tamanho certo (a Mothership bem maior que o Grunt).
  • SPR_UI_Panel colocado numa Image de UI com Image Type = Sliced estica sem deformar os cantos.
  • Um texto TextMeshPro com a fonte m5x7 Raster no tamanho 16 escreve "Ação, não, você" com os acentos e sem borrão.
  • As 5 Sorting Layers aparecem na ordem da Seção 6.4.
  • Em Art/Grid/, SPR_Grid_Floor mostra 4 sprites depois de fatiado.
  • (Recomendado, e economiza muita dor depois) File > Build Profiles > Web > Build And Run gera o build e abre o navegador com o projeto vazio (uma tela azul/cinza). Isso prova que o módulo Web está funcionando antes de existir qualquer código.
  • git lfs ls-files lista os PNGs que você já tiver no projeto (se ainda não tiver nenhum, a lista fica vazia, o que é normal).

Problemas comuns:

  • "Web Build Support" faltando no Build Profiles: o módulo Web não foi instalado. No Hub: Installs → engrenagem da versão → Add modules.
  • Erros vermelhos do DOTween depois de criar o asmdef: rode de novo Tools > Demigiant > DOTween Utility Panel > Setup DOTween… com Create ASMDEF marcado, e confira que DOTween.Modules está nas referências do Armageddon.asmdef.
  • Uma spritesheet aparece como uma imagem só na cena: ela ficou como Single. Troque o Sprite Mode para Multiple e fatie (Passo 7, itens 2 e 3).
  • A fatia saiu deslocada ou cortando o desenho: o Pixel Size do slice está errado. Todas as células são 32×32, menos as duas da Mothership (64×64).
  • Texto borrado ou com contorno "fantasma": o Font Asset foi gerado em modo SDF. Refaça com Render Mode = RASTER_HINTED.
  • Letras acentuadas aparecem como quadradinhos: o Font Asset foi gerado com um Character Set sem acentos. Refaça com Extended ASCII. O caractere "→" não existe na fonte mesmo (Seção 6.3).
  • Sprites borrados ou "piscando": o sprite foi importado antes do Preset existir, ou está fora de Assets/_Project/Art/. Selecione-o e clique no ícone de Preset → SpriteImporter_PixelArt.
  • Os PNGs foram para o Git como arquivo normal: o git lfs install não foi rodado, ou o .gitattributes foi criado depois do commit. Rode git lfs migrate import --include="*.png,*.wav,*.ogg" antes de enviar o repositório para qualquer lugar.

Fase 1 — Arquitetura: bootstrap, serviços, cenas, save e pausa​

Objetivo desta fase: o jogo abre na cena Boot, inicializa os serviços, mostra o logo e passa para o MainMenu com uma transição suave. O progresso do jogador é salvo em disco com versão, e o jogo pausa sozinho quando perde o foco. Nada de gameplay ainda: esta é a "fundação" que todas as outras fases usam.

Conceitos novos:

  • Serviço: um sistema que vive o jogo inteiro e não pertence a nenhuma cena (save, troca de cenas, pausa e, depois, áudio). Todos ficam num objeto chamado [Services], que nunca é destruído.
  • Service Locator (Services): uma classe estática com uma propriedade para cada serviço. Qualquer script escreve Services.Save.Profile em vez de procurar o objeto na cena. É simples e explícito: você sabe exatamente quais serviços existem olhando um arquivo só.
  • [RuntimeInitializeOnLoadMethod]: um atributo que faz a Unity chamar um método estático antes da primeira cena carregar, em qualquer cena. É isso que permite apertar Play direto na cena Gameplay e ter os serviços funcionando, sem precisar passar pela Boot.
  • DontDestroyOnLoad: marca um objeto para sobreviver à troca de cena.
  • Save versionado: o arquivo de save guarda o número da versão do formato. Quando o formato mudar numa atualização do jogo, o código sabe converter um save antigo em vez de apagá-lo.
  • Escrita atômica: o save é escrito num arquivo temporário e só depois substitui o original. Se o computador desligar no meio da escrita, o save antigo continua inteiro.
  • Time.timeScale: o multiplicador de tempo da Unity. Com 0, tudo o que usa Time.deltaTime (movimento, animações, timers) congela; é assim que o jogo pausa. A UI de pausa usa Time.unscaledDeltaTime, que ignora o timeScale.

Passo 1 — Referência do Unity UI no asmdef​

A transição de cena usa componentes de UI (Canvas, Image). Selecione Assets/_Project/Scripts/Armageddon.asmdef e, em Assembly Definition References, adicione UnityEngine.UI. Apply.

Passo 2 — Os dados salvos: PlayerProfile​

O PlayerProfile é tudo o que o jogo guarda entre sessões. Nesta fase ele só tem o básico; cada fase que precisar salvar algo acrescenta campos aqui.

// Caminho: Assets/_Project/Scripts/Core/PlayerProfile.cs
using System;
using System.Collections.Generic;

namespace Armageddon.Core
{
// POR QUE: o jogo precisa lembrar coisas entre uma sessão e outra (recorde de Wave, Planet Cores e
// Skins liberados, qual Core está selecionado). Não há moeda permanente nem Perks (Seção 5.1).
// Esta classe é "a ficha do jogador": tudo o que é salvo em disco mora aqui, e nada mais.
// ESTRATÉGIA: é um objeto de dados puro (não é MonoBehaviour, não fica em cena, não tem lógica).
// Quem lê e grava é o SaveService, convertendo para JSON com o JsonUtility da Unity.
// O JsonUtility só grava campos públicos de tipos simples, List e classes/structs [Serializable]:
// Dictionary NÃO é suportado: listas de ids (como unlockedCoreIds) cumprem esse papel.
// Exceção à convenção de nomes: por ser um objeto de dados, os campos são públicos em camelCase,
// e esses nomes viram as chaves do JSON. Renomear um campo é uma mudança de versão do save.
// [Serializable] = "este tipo pode ser convertido em texto/bytes"; sem ele o JsonUtility ignora a classe.
[Serializable]
public sealed class PlayerProfile
{
public int version; // versão do FORMATO do save (não do jogo); usada pelo SaveService.Migrate
public int launchCount; // quantas vezes o jogo foi aberto (incrementado pelo GameBootstrap)
public int bestWave; // recorde de Wave: a maior Wave alcançada em qualquer run (Seção 5.1)
public List<string> unlockedCoreIds = new List<string>(); // ids dos Planet Cores liberados
public List<string> unlockedSkinIds = new List<string>(); // ids no formato "<Core>_<Skin>"
public string selectedCoreId;
public string selectedSkinId;

// Cria a ficha de um jogador que acabou de instalar o jogo.
// POR QUE um método "fábrica" em vez de valores no construtor: deixa explícito, num lugar só,
// qual é o estado inicial, e o SaveService usa o mesmo método no "Resetar progresso".
public static PlayerProfile CreateNew()
{
return new PlayerProfile
{
version = SaveService.CurrentVersion,
unlockedCoreIds = new List<string> { "Terra" },
// A Skin "Classic" de cada Core vem liberada; ela só pode ser usada quando o Core for desbloqueado.
unlockedSkinIds = new List<string> { "Terra_Classic", "Ice_Classic", "Magma_Classic" },
selectedCoreId = "Terra",
selectedSkinId = "Terra_Classic",
};
}
}
}

Campo novo não precisa de migração. Ao ler um save antigo, o JsonUtility deixa com o valor padrão (0, null ou a lista vazia do inicializador) qualquer campo que não existia no arquivo. Só é preciso subir a versão e escrever uma migração quando um campo muda de nome ou de significado.

Passo 3 — Onde o save é guardado: SaveStorage​

No Editor, o save é um arquivo em Application.persistentDataPath. No build Web (a plataforma deste jogo, Seção 7.3), o jogo usa o armazenamento do navegador via PlayerPrefs, que a Unity grava no IndexedDB. Os dois caminhos ficam atrás da mesma interface, então o resto do jogo não sabe onde o save mora.

// Caminho: Assets/_Project/Scripts/Core/SaveStorage.cs
using System.IO;
using UnityEngine;

namespace Armageddon.Core
{
// POR QUE: o JSON do save mora em lugares diferentes conforme a plataforma (arquivo no Editor,
// armazenamento do navegador na Web). O SaveService não deveria ter "if Web / if Editor" espalhado.
// ESTRATÉGIA: uma interface (um "contrato": lista de métodos sem implementação). O SaveService só
// conhece ISaveStorage; cada plataforma entrega uma classe que cumpre o contrato do seu jeito.
// Bônus: num teste dá para passar um armazenamento falso, em memória.
public interface ISaveStorage
{
string Read(); // null se ainda não existe save
string ReadBackup(); // null se não existe backup
void Write(string json);
void Delete();
}

// POR QUE: alguém precisa decidir QUAL implementação de ISaveStorage usar nesta plataforma.
// ESTRATÉGIA: classe estática com um método "fábrica". A escolha é feita em tempo de compilação
// com #if (diretivas de pré-processador): o código do outro ramo nem entra no build.
// UNITY_WEBGL = build Web; !UNITY_EDITOR = "não estou rodando dentro do Editor".
public static class SaveStorage
{
// Devolve o armazenamento certo para a plataforma atual. Chamado uma vez, pelo GameBootstrap.
public static ISaveStorage CreateForPlatform()
{
#if UNITY_WEBGL && !UNITY_EDITOR
return new PlayerPrefsSaveStorage();
#else
// Application.persistentDataPath = pasta que a Unity garante que sobrevive entre sessões
// (no Editor, uma pasta do usuário no computador).
return new FileSaveStorage(Application.persistentDataPath);
#endif
}
}

// POR QUE: o Editor (e um futuro build de desktop ou de celular) grava o save num arquivo. Se o
// computador desligar no meio da gravação, um arquivo pela metade apagaria o progresso do jogador.
// ESTRATÉGIA: escrita atômica (grava num .tmp e troca de uma vez) + uma cópia .bak do save anterior.
// Classe C# pura: não precisa de cena, só de System.IO.
public sealed class FileSaveStorage : ISaveStorage
{
private readonly string _path; // profile.json — o save "de verdade"
private readonly string _tempPath; // profile.json.tmp — rascunho durante a escrita
private readonly string _backupPath; // profile.json.bak — o save anterior, se o principal corromper

// Monta os três caminhos uma vez só. Path.Combine usa a barra certa para cada sistema operacional.
public FileSaveStorage(string folder)
{
_path = Path.Combine(folder, "profile.json");
_tempPath = _path + ".tmp";
_backupPath = _path + ".bak";
}

// Lê o save principal (ou null se o jogador é novo).
public string Read() => File.Exists(_path) ? File.ReadAllText(_path) : null;

// Lê o backup; o SaveService só chama se o principal estiver corrompido.
public string ReadBackup() => File.Exists(_backupPath) ? File.ReadAllText(_backupPath) : null;

// Grava o JSON de forma atômica.
public void Write(string json)
{
// 1) escreve tudo num arquivo temporário; 2) troca de uma vez só, guardando o anterior como .bak.
// File.Replace é uma operação única do sistema de arquivos: ou acontece inteira, ou não acontece.
File.WriteAllText(_tempPath, json);
if (File.Exists(_path))
File.Replace(_tempPath, _path, _backupPath);
else
File.Move(_tempPath, _path); // primeiro save: não há o que substituir
}

// Apaga tudo (usado em testes e ferramentas de desenvolvimento).
public void Delete()
{
foreach (var path in new[] { _path, _tempPath, _backupPath })
{
if (File.Exists(path)) File.Delete(path);
}
}
}

// POR QUE: no navegador não existe sistema de arquivos persistente acessível pelo jogo.
// ESTRATÉGIA: usar PlayerPrefs (o "dicionário de chave → valor" da Unity), que na Web a Unity grava
// no IndexedDB do navegador. Guardamos a versão anterior numa segunda chave, imitando o .bak.
public sealed class PlayerPrefsSaveStorage : ISaveStorage
{
private const string Key = "profile";
private const string BackupKey = "profile.bak";

// Mesmo contrato do FileSaveStorage, mas lendo/gravando chaves do PlayerPrefs.
public string Read() => PlayerPrefs.HasKey(Key) ? PlayerPrefs.GetString(Key) : null;

public string ReadBackup() => PlayerPrefs.HasKey(BackupKey) ? PlayerPrefs.GetString(BackupKey) : null;

// Copia o save atual para o backup e grava o novo.
public void Write(string json)
{
if (PlayerPrefs.HasKey(Key)) PlayerPrefs.SetString(BackupKey, PlayerPrefs.GetString(Key));
PlayerPrefs.SetString(Key, json);
// PlayerPrefs.Save força a gravação agora; sem isso a Unity só grava ao fechar,
// e na Web o jogador pode fechar a aba sem que isso aconteça.
PlayerPrefs.Save();
}

public void Delete()
{
PlayerPrefs.DeleteKey(Key);
PlayerPrefs.DeleteKey(BackupKey);
PlayerPrefs.Save();
}
}
}

Passo 4 — O serviço de save: SaveService​

// Caminho: Assets/_Project/Scripts/Core/SaveService.cs
using System;
using UnityEngine;

namespace Armageddon.Core
{
// POR QUE: alguém precisa carregar o PlayerProfile ao abrir o jogo, recuperar de um save corrompido,
// converter saves antigos (migração) e gravar quando algo muda. Sem um dono único, cada tela
// gravaria o save do seu jeito.
// ESTRATÉGIA: um serviço (ver "Conceitos novos"): vive o jogo inteiro, acessado por Services.Save.
// Não é MonoBehaviour: não precisa de cena nem de Update, então é uma classe C# comum criada com `new`.
// Recebe o ISaveStorage pronto no construtor (não sabe se é arquivo ou navegador).
public sealed class SaveService
{
// Suba este número quando o formato do save mudar de um jeito que exija migração (ver Migrate).
public const int CurrentVersion = 1;

private readonly ISaveStorage _storage;

// O perfil carregado. "private set" = qualquer um lê, só o SaveService troca o objeto inteiro.
public PlayerProfile Profile { get; private set; }

// Disparado depois de "Resetar progresso" (Settings, Fase 11): quem guarda dados do perfil em cache deve recarregar.
// "event Action" é um evento C#: outros scripts se inscrevem com += e são avisados quando ele é
// disparado (Invoke). Quem dispara não precisa saber quem está ouvindo — isso desacopla os sistemas.
public event Action ProfileReset;

public SaveService(ISaveStorage storage)
{
_storage = storage;
}

// Carrega o perfil ao abrir o jogo (chamado pelo GameBootstrap).
// Ordem de tentativa: save principal → backup → perfil novo. O operador ?? significa
// "se o da esquerda for null, usa o da direita".
public void Load()
{
Profile = TryParse(_storage.Read(), "principal")
?? TryParse(_storage.ReadBackup(), "backup")
?? PlayerProfile.CreateNew();
Migrate(Profile);
}

// Grava o perfil atual. Quem muda o perfil (conquista liberada, fim de run...) chama este método.
public void Save()
{
try
{
_storage.Write(JsonUtility.ToJson(Profile));
}
catch (Exception e)
{
// Falha de disco não pode derrubar o jogo: registra e segue. O próximo Save tenta de novo.
Debug.LogError($"[SaveService] Falha ao salvar: {e.Message}");
}
}

// "Resetar progresso" das Settings: troca por um perfil novo, grava e avisa os interessados.
public void ResetProgress()
{
Profile = PlayerProfile.CreateNew();
Save();
// ?. = só chama Invoke se houver alguém inscrito (senão o evento é null).
ProfileReset?.Invoke();
}

// Converte o texto JSON em PlayerProfile, devolvendo null se o texto não existe ou está corrompido.
// POR QUE separado: Load chama duas vezes (principal e backup) com a mesma lógica.
private static PlayerProfile TryParse(string json, string source)
{
if (string.IsNullOrEmpty(json)) return null;
try
{
var profile = JsonUtility.FromJson<PlayerProfile>(json);
// version > 0 confirma que era um save de verdade (um JSON vazio "{}" viria com version 0).
if (profile != null && profile.version > 0) return profile;
}
catch (Exception e)
{
Debug.LogWarning($"[SaveService] Save {source} corrompido: {e.Message}");
}
return null;
}

// Traz um save antigo para o formato atual, um passo de versão por vez (1→2, 2→3...).
// POR QUE um passo por vez: cada migração só precisa saber converter da versão anterior.
private static void Migrate(PlayerProfile profile)
{
if (profile.version > CurrentVersion)
{
// Save de uma versão mais nova do jogo (ex.: o jogador voltou para um APK antigo). Não mexe em nada.
Debug.LogWarning($"[SaveService] Save da versão {profile.version}, mais nova que a do jogo ({CurrentVersion}).");
return;
}

while (profile.version < CurrentVersion)
{
// Quando existir a versão 2, a conversão da 1 para a 2 entra aqui, por exemplo:
// if (profile.version == 1) profile.campoNovo = ConverterDe(profile.campoAntigo);
profile.version++;
}
}
}
}

Passo 5 — Pausa e tempo: GameClock​

A pausa pode ter mais de um motivo ao mesmo tempo: o jogador apertou pausa e a run acabou, com o Results na tela (Fase 8). O GameClock guarda um conjunto de motivos, e o jogo só volta a andar quando todos saem.

// Caminho: Assets/_Project/Scripts/Core/GameClock.cs
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Armageddon.Core
{
// POR QUE: a pausa pode ter mais de um motivo ao mesmo tempo, e cada um precisa sair separadamente.
// ESTRATÉGIA: um enum (lista fechada de nomes) em vez de strings: o compilador pega erros de digitação.
public enum PauseReason
{
Manual, // botão de pausa, botão "voltar" ou o app perdeu o foco
Modal, // uma janela que congela o jogo sem ser a pausa (ex.: o Results no fim da run, Fase 8)
}

// POR QUE: se vários scripts mexessem em Time.timeScale, um "despausaria" o que o outro pausou.
// ESTRATÉGIA: um único dono do Time.timeScale. Nenhum outro script deve alterar Time.timeScale diretamente.
// Guarda um conjunto (HashSet) de motivos de pausa: o jogo só volta a andar quando o conjunto esvazia.
// É MonoBehaviour (um componente preso a um GameObject) e o GameBootstrap o adiciona ao objeto
// [Services]. Hoje não usa Update, mas fica junto dos serviços de cena e aparece na Hierarchy durante o Play.
// "sealed" = ninguém pode herdar desta classe (deixa a intenção clara e o código um pouco mais rápido).
public sealed class GameClock : MonoBehaviour
{
// HashSet: coleção sem repetição. Pausar duas vezes pelo mesmo motivo conta como uma.
private readonly HashSet<PauseReason> _reasons = new HashSet<PauseReason>();
private float _timeScale = 1f; // escala "quando não está pausado" (câmera lenta futura)
private bool _wasPaused; // último estado avisado, para disparar PauseChanged só quando muda

public bool IsPaused => _reasons.Count > 0;

// Só existe pausa durante a run. Nos menus, perder o foco não pausa nada.
public bool IsGameplayActive { get; private set; }

// true = acabou de pausar; false = acabou de voltar. A PauseView (Fase 8) escuta este evento.
public event Action<bool> PauseChanged;

// Chamado a cada cena carregada. Uma cena nova sempre começa sem pausa, inclusive quando
// "jogar de novo" recarrega a própria Gameplay (Fase 8).
public void SetGameplayActive(bool active)
{
IsGameplayActive = active;
_reasons.Clear();
Apply();
}

// Pergunta se um motivo específico está ativo (ex.: a PauseView só aparece na pausa Manual).
public bool IsPausedBy(PauseReason reason) => _reasons.Contains(reason);

// Adiciona um motivo de pausa. HashSet.Add devolve false se já existia: aí não há o que reaplicar.
public void Pause(PauseReason reason)
{
if (_reasons.Add(reason)) Apply();
}

// Remove um motivo. O jogo só volta se não sobrar nenhum outro.
public void Resume(PauseReason reason)
{
if (_reasons.Remove(reason)) Apply();
}

// Botão de pausa / tecla "voltar": alterna a pausa Manual.
public void TogglePause()
{
if (_reasons.Contains(PauseReason.Manual)) Resume(PauseReason.Manual);
else Pause(PauseReason.Manual);
}

// Escala de tempo da run quando não está pausada (1 = normal). Reservado para efeitos como câmera lenta.
public void SetTimeScale(float scale)
{
_timeScale = Mathf.Max(0f, scale);
Apply();
}

// Chamado pelo AppLifecycle quando o app vai para segundo plano ou perde o foco.
// Vira uma pausa MANUAL de propósito: ao voltar, o jogo continua pausado até o jogador tocar em "continuar".
public void HandleApplicationSuspended()
{
if (IsGameplayActive) Pause(PauseReason.Manual);
}

// Único lugar que escreve Time.timeScale. Também avisa (evento) quando o estado pausado muda.
private void Apply()
{
Time.timeScale = IsPaused ? 0f : _timeScale;
if (IsPaused == _wasPaused) return;
_wasPaused = IsPaused;
Debug.Log(IsPaused ? "[GameClock] Pausado" : "[GameClock] Retomado");
PauseChanged?.Invoke(IsPaused);
}
}
}

Passo 6 — Troca de cenas: SceneLoader​

// Caminho: Assets/_Project/Scripts/Core/SceneLoader.cs
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

namespace Armageddon.Core
{
// POR QUE: trocar de cena por texto ("Gameplay") espalha strings pelo código, e um erro de digitação
// só aparece rodando. O enum dá autocompletar e erro de compilação.
// Os nomes precisam ser IGUAIS aos nomes dos arquivos de cena (Fase 0, Passo 8).
public enum GameScene { Boot, MainMenu, Gameplay }

// POR QUE: toda troca de cena precisa do mesmo ritual: escurecer a tela, carregar sem travar,
// clarear, e avisar o resto do jogo qual cena entrou (o GameClock precisa saber se é a Gameplay).
// ESTRATÉGIA: um serviço MonoBehaviour no [Services] (sobrevive às trocas de cena).
// É MonoBehaviour porque precisa de coroutines (StartCoroutine só existe em MonoBehaviour).
// Cria o próprio Canvas de transição por código: não precisa de prefab.
public sealed class SceneLoader : MonoBehaviour
{
private const float FadeDuration = 0.25f; // segundos de cada metade do fade

private CanvasGroup _fade; // CanvasGroup controla a transparência (alpha) de toda a tela preta de uma vez

public bool IsLoading { get; private set; }

// Avisado depois que uma cena termina de carregar (quem precisar reagir se inscreve).
public event Action<GameScene> SceneLoaded;

// Awake: método que a Unity chama UMA vez, assim que o componente é criado (antes de Start).
// Regra deste projeto: no Awake, só configure a si mesmo (ver a nota depois do GameBootstrap).
// Aqui: cria a tela preta e se inscreve no evento da Unity que avisa "uma cena carregou".
private void Awake()
{
_fade = CreateFadeOverlay();
SceneManager.sceneLoaded += HandleSceneLoaded;
}

// OnDestroy: chamado quando o objeto é destruído. Toda inscrição (+=) precisa de uma
// desinscrição (-=), senão o evento continua apontando para um objeto morto.
private void OnDestroy()
{
SceneManager.sceneLoaded -= HandleSceneLoaded;
}

// O único método que o resto do jogo chama: Services.Scenes.Load(GameScene.MainMenu).
public void Load(GameScene scene)
{
if (IsLoading) return; // ignora cliques repetidos durante a transição
StartCoroutine(LoadRoutine(scene));
}

// Coroutine: um método que retorna IEnumerator e pode "pausar" no meio com yield return,
// continuando no próximo frame (ou depois de outra coroutine). É como escrever uma sequência
// no tempo ("escurece, depois carrega, depois clareia") sem travar o jogo e sem Update.
private IEnumerator LoadRoutine(GameScene scene)
{
IsLoading = true;
_fade.blocksRaycasts = true; // bloqueia toques enquanto a tela escurece
yield return Fade(0f, 1f);

// LoadSceneAsync carrega em segundo plano; yield return null espera um frame por vez até terminar.
var operation = SceneManager.LoadSceneAsync(scene.ToString());
while (!operation.isDone) yield return null;

yield return Fade(1f, 0f);
_fade.blocksRaycasts = false;
IsLoading = false;
}

// Roda em TODA cena carregada, inclusive a primeira quando você aperta Play direto numa cena.
// Converte o nome da cena no enum; cenas que não estão no enum são ignoradas.
private void HandleSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (!Enum.TryParse(scene.name, out GameScene gameScene)) return;
Services.Clock.SetGameplayActive(gameScene == GameScene.Gameplay);
SceneLoaded?.Invoke(gameScene);
}

// Anima o alpha da tela preta de "from" até "to" ao longo de FadeDuration.
private IEnumerator Fade(float from, float to)
{
// Tempo "unscaled": o fade funciona mesmo com o jogo pausado (timeScale = 0).
for (float t = 0f; t < FadeDuration; t += Time.unscaledDeltaTime)
{
// Mathf.Lerp(a, b, x) = valor entre a e b na proporção x (0 → a, 1 → b).
_fade.alpha = Mathf.Lerp(from, to, t / FadeDuration);
yield return null;
}
_fade.alpha = to; // garante o valor final exato
}

// Monta por código: Canvas → Image preta esticada na tela toda → CanvasGroup para o alpha.
// POR QUE por código: o SceneLoader nasce antes de qualquer cena, então não há prefab na cena para arrastar.
private CanvasGroup CreateFadeOverlay()
{
// new GameObject(nome, componentes...) cria o objeto já com esses componentes.
var root = new GameObject("SceneFade", typeof(Canvas), typeof(GraphicRaycaster), typeof(CanvasGroup));
// SetParent(..., false): vira filho do [Services] (e portanto também sobrevive às trocas de cena).
root.transform.SetParent(transform, false);

var canvas = root.GetComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 1000; // acima de qualquer UI do jogo

var image = new GameObject("Black", typeof(Image)).GetComponent<Image>();
image.transform.SetParent(root.transform, false);
image.color = Color.black;
// Âncoras de 0 a 1 com offsets zero = o retângulo ocupa o pai inteiro (a tela toda).
var rect = image.rectTransform;
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;

var group = root.GetComponent<CanvasGroup>();
group.alpha = 0f;
group.blocksRaycasts = false; // só bloqueia toques durante a transição
group.interactable = false;
return group;
}
}
}

Passo 7 — Ciclo de vida do app: AppLifecycle​

// Caminho: Assets/_Project/Scripts/Core/AppLifecycle.cs
using UnityEngine;
using UnityEngine.InputSystem;

namespace Armageddon.Core
{
// POR QUE: no navegador o jogador troca de aba, minimiza a janela ou fecha o jogo a qualquer momento.
// Se isso acontecer no meio da run, o jogo precisa salvar e pausar; e a tecla Esc precisa fazer algo sensato.
// ESTRATÉGIA: um MonoBehaviour no [Services] que só escuta as mensagens de sistema que a Unity manda
// para todo MonoBehaviour (OnApplicationPause/Focus/Quit) e repassa para o SaveService e o GameClock.
// Ele não decide nada sobre a pausa: quem decide é o GameClock.
public sealed class AppLifecycle : MonoBehaviour
{
// Mensagem da Unity. Celular e desktop: o app foi para segundo plano. No navegador quase não chega
// (quem avisa é o OnApplicationFocus), mas tratar não custa nada e deixa pronto para outra plataforma.
private void OnApplicationPause(bool paused)
{
if (paused) Suspend();
}

// Mensagem da Unity. Web e desktop: a aba ou a janela perdeu o foco.
private void OnApplicationFocus(bool focused)
{
// No Editor, clicar no Inspector tira o foco do Game view: pausar aí atrapalharia o desenvolvimento.
if (Application.isEditor) return;
if (!focused) Suspend();
}

// Mensagem da Unity ao fechar o app. No navegador ela quase nunca chega (fechar a aba encerra o jogo),
// por isso o save principal acontece em Suspend. O ?. evita erro se os serviços já não existirem.
private void OnApplicationQuit()
{
Services.Save?.Save();
}

// Update: chamado pela Unity uma vez a cada frame. É o lugar de ler input.
private void Update()
{
// Esc alterna a pausa. (Num futuro build Android, o botão "voltar" também chega como Escape.)
// Keyboard.current é null quando não há teclado, por isso a checagem.
var keyboard = Keyboard.current;
if (keyboard == null || !keyboard.escapeKey.wasPressedThisFrame) return;

// Na run, "voltar" alterna a pausa. Nos menus, cada tela trata o "voltar" (Fase 9).
if (Services.Clock.IsGameplayActive) Services.Clock.TogglePause();
}

// O que fazer ao sair de cena por qualquer motivo: salvar e pedir pausa.
// "static" porque não usa nenhum campo deste objeto, só os Services.
private static void Suspend()
{
if (!Services.IsReady) return;
Services.Save.Save();
Services.Clock.HandleApplicationSuspended();
}
}
}

Passo 8 — O ponto de acesso: Services​

// Caminho: Assets/_Project/Scripts/Core/Services.cs
namespace Armageddon.Core
{
// POR QUE: qualquer script precisa chegar ao save, à troca de cena ou à pausa sem ter que procurar
// objetos na cena (FindObjectOfType é lento e frágil) nem arrastar referências no Inspector em toda cena.
// ESTRATÉGIA: Service Locator — uma classe estática com uma propriedade por serviço.
// "static" = existe uma só, sem precisar de instância: qualquer script escreve Services.Save.Profile.
// Preenchida pelo GameBootstrap antes da primeira cena carregar.
// É "partial": uma fase futura que precise de um serviço novo o acrescenta num arquivo próprio
// (Services.<Nome>.cs), sem mexer neste.
public static partial class Services
{
// "private set": só esta classe troca os serviços; o resto do jogo apenas lê.
public static SaveService Save { get; private set; }
public static SceneLoader Scenes { get; private set; }
public static GameClock Clock { get; private set; }

// true depois que o GameBootstrap registrou tudo. Quem pode rodar antes disso confere aqui.
public static bool IsReady { get; private set; }

// Chamado uma vez pelo GameBootstrap. "internal" = só código da mesma assembly (Armageddon) enxerga.
internal static void Register(SaveService save, SceneLoader scenes, GameClock clock)
{
Save = save;
Scenes = scenes;
Clock = clock;
IsReady = true;
}

// Zera tudo no começo de cada Play no Editor (ver GameBootstrap.ResetStatics).
internal static void Clear()
{
Save = null;
Scenes = null;
Clock = null;
IsReady = false;
}
}
}

Passo 9 — Atalhos de desenvolvimento: DevShortcuts​

Enquanto os menus não existem, precisamos de um jeito de trocar de cena e testar o save. Este script só existe no Editor e em builds de desenvolvimento: ele some sozinho do build final.

// Caminho: Assets/_Project/Scripts/Core/DevShortcuts.cs
// Todo o arquivo fica dentro deste #if: no build final (sem "Development Build") ele simplesmente não existe.
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using UnityEngine;
using UnityEngine.InputSystem;

namespace Armageddon.Core
{
// POR QUE: enquanto os menus não existem, precisamos trocar de cena e mexer no save para testar.
// ESTRATÉGIA: um MonoBehaviour no [Services] (o GameBootstrap só o adiciona em Editor/dev build)
// que lê teclas no Update e chama os serviços. Nada de gameplay depende dele.
// F1 = MainMenu · F2 = Gameplay · F5 = libera todos os Planet Cores e Skins e salva · F9 = apaga o progresso.
public sealed class DevShortcuts : MonoBehaviour
{
// Lê as teclas de teste a cada frame. wasPressedThisFrame = true só no frame em que a tecla desceu
// (segurar a tecla não repete a ação).
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.f1Key.wasPressedThisFrame) Services.Scenes.Load(GameScene.MainMenu);
if (keyboard.f2Key.wasPressedThisFrame) Services.Scenes.Load(GameScene.Gameplay);

if (keyboard.f5Key.wasPressedThisFrame)
{
// Testar a Core Select sem cumprir as conquistas. Clear + AddRange troca o conteúdo da lista.
var profile = Services.Save.Profile;
profile.unlockedCoreIds.Clear();
profile.unlockedCoreIds.AddRange(new[] { "Terra", "Ice", "Magma" });
profile.unlockedSkinIds.Clear();
profile.unlockedSkinIds.AddRange(new[]
{
"Terra_Classic", "Terra_Ocean", "Terra_Desert",
"Ice_Classic", "Ice_Aurora", "Ice_Crystal",
"Magma_Classic", "Magma_Obsidian", "Magma_Solar",
});
Services.Save.Save();
Debug.Log("[Dev] Todos os Planet Cores e Skins liberados");
}

if (keyboard.f9Key.wasPressedThisFrame)
{
Services.Save.ResetProgress();
Debug.Log("[Dev] Progresso apagado.");
}
}
}
}
#endif

Passo 10 — Quem liga tudo: GameBootstrap​

// Caminho: Assets/_Project/Scripts/Core/GameBootstrap.cs
using UnityEngine;

namespace Armageddon.Core
{
// POR QUE: os serviços precisam existir ANTES de qualquer cena, inclusive quando você aperta Play
// direto na cena Gameplay (sem passar pela Boot). Se dependessem de um objeto na cena Boot,
// testar a Gameplay sozinha quebraria.
// ESTRATÉGIA: classe estática com [RuntimeInitializeOnLoadMethod], que a Unity chama sozinha antes
// da primeira cena. Ela cria o objeto [Services], marca para não ser destruído, cria cada serviço
// na ordem certa e registra tudo no Services. É o único lugar que "monta" o jogo.
public static class GameBootstrap
{
// Com "Enter Play Mode Options" ligado (sem recarregar o domínio), variáveis estáticas sobrevivem
// entre um Play e outro. Este método zera tudo no começo de cada Play.
// SubsystemRegistration é o momento mais cedo possível, antes do Initialize abaixo.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
Services.Clear();
}

// BeforeSceneLoad: roda depois do ResetStatics e antes de a primeira cena carregar.
// Cria e registra todos os serviços.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Initialize()
{
if (Services.IsReady) return; // proteção: nunca montar duas vezes

var root = new GameObject("[Services]");
// DontDestroyOnLoad: o objeto sobrevive às trocas de cena (fica numa "cena" especial na Hierarchy).
Object.DontDestroyOnLoad(root);

// O save vem primeiro: os outros serviços podem querer ler o perfil.
var save = new SaveService(SaveStorage.CreateForPlatform());
save.Load();
save.Profile.launchCount++;
save.Save();

// AddComponent cria o componente no objeto e já chama o Awake dele nesta mesma linha.
var clock = root.AddComponent<GameClock>();
var scenes = root.AddComponent<SceneLoader>();
root.AddComponent<AppLifecycle>();
#if UNITY_EDITOR || DEVELOPMENT_BUILD
root.AddComponent<DevShortcuts>();
#endif

Services.Register(save, scenes, clock);
Debug.Log($"[GameBootstrap] Serviços prontos. Abertura nº {save.Profile.launchCount}. Save em: {Application.persistentDataPath}");
}
}
}

Por que o SceneLoader usa Services.Clock se ele é registrado depois? O AddComponent roda o Awake na hora, mas o SceneLoader só usa Services.Clock quando uma cena carrega, e isso acontece depois do Initialize terminar. Regra geral para serviços: no Awake, só configure a si mesmo; use outros serviços a partir do Start ou de eventos.

Passo 11 — A cena Boot: BootSequence​

// Caminho: Assets/_Project/Scripts/Core/BootSequence.cs
using System.Collections;
using UnityEngine;

namespace Armageddon.Core
{
// POR QUE: a cena Boot precisa mostrar o logo por um tempo mínimo (senão ele pisca e some) e depois
// seguir para o Main Menu. Os serviços já existem (GameBootstrap), então só falta a sequência visual.
// ESTRATÉGIA: um MonoBehaviour pequeno, colocado na cena Boot, que usa Start como coroutine.
// Este jogo não tem tela de consentimento (sem anúncios nem analytics, Seção 7): do logo vai direto ao menu.
public sealed class BootSequence : MonoBehaviour
{
// [SerializeField]: mostra um campo privado no Inspector para você ajustar sem mexer no código
// (ver a convenção da Seção 10.1). O valor digitado no Inspector vence o valor do código.
[SerializeField] private float _minimumLogoTime = 1f;

// Start: chamado pela Unity uma vez, no primeiro frame em que o objeto está ativo, DEPOIS de todos
// os Awake da cena. Se Start retorna IEnumerator, a Unity o roda como coroutine automaticamente.
private IEnumerator Start()
{
// Realtime: espera segundos de verdade, mesmo que o timeScale esteja em 0.
yield return new WaitForSecondsRealtime(_minimumLogoTime);
Services.Scenes.Load(GameScene.MainMenu);
}
}
}

Monte as cenas:

  1. Boot: crie os objetos vazios de agrupamento [Systems] e [UI] (convenção da Seção 10.1). Em [Systems], adicione um objeto BootSequence com o componente BootSequence. Em [UI], crie UI > Canvas e, dentro dele, UI > Text - TextMeshPro escrito "8-Bit Armageddon", com a fonte m5x7 Raster no tamanho 16 (Fase 0, Passo 7). Esse é o "logo" provisório.
  2. MainMenu: crie [Systems], [UI] e, no Canvas, um texto "Main Menu".
  3. Gameplay: crie [Systems], [World] e [UI]. Para ver a pausa funcionando, coloque um planeta girando em [World]: abra a setinha de Art/Planet/SPR_Planet_Terra_Classic_Rotate, selecione os 32 sprites (clique no primeiro, Shift+clique no último) e arraste todos juntos para a cena. A Unity cria um objeto com Animator e um clipe de animação; salve o clipe em Assets/_Project/Art/Planet/ como ANIM_Planet_Rotate. Abra Window > Animation > Animation, selecione o objeto, ative Show Sample Rate no menu ⋮ da janela e troque Samples para 4 (os 4 fps do planeta, Seção 6.4). No SpriteRenderer, escolha a Sorting Layer World. (Este planeta de teste ainda não passa pela projeção isométrica: ela entra na Fase 2, que apaga este objeto.)

Passo 12 — Configuração base da Localization​

Os textos só serão traduzidos na Fase 11, mas os idiomas precisam existir desde já, para que todo texto novo nasça localizável.

  1. Edit > Project Settings > Localization → Create. Salve o asset em Assets/_Project/Localization/ como LocalizationSettings.
  2. Na mesma tela, clique em Locale Generator, marque English (en) e Portuguese (Brazil) (pt-BR) e clique em Generate Locales, salvando em Assets/_Project/Localization/Locales/.
  3. Window > Asset Management > Localization Tables → New Table Collection → tipo String Table Collection, nome UI, marcando os dois idiomas → Create, em Assets/_Project/Localization/.
  4. Crie a primeira chave, menu.play, com os valores "Play" (en) e "Jogar" (pt-BR). Ela será usada no botão do Main Menu na Fase 9.

Passo 13 — Commit​

git add . e git commit -m "Phase 1: bootstrap, services, scene loader, versioned save, pause".

✅ Checkpoint:

  • Com a cena Boot aberta, aperte Play: o texto "8-Bit Armageddon" aparece por ~1 s, a tela escurece e clareia, e aparece o "Main Menu". O Console mostra [GameBootstrap] Serviços prontos. Abertura nº 1.
  • Durante o Play, a janela Hierarchy mostra DontDestroyOnLoad → [Services] com os componentes GameClock, SceneLoader, AppLifecycle e DevShortcuts.
  • F2 leva para o Gameplay com o planeta girando; F1 volta para o menu. As duas trocas têm o fade.
  • No Gameplay, Esc congela o planeta (Console: [GameClock] Pausado) e Esc de novo o faz voltar a girar. No Main Menu, Esc não faz nada.
  • Parar e apertar Play de novo mostra Abertura nº 2: o save sobreviveu.
  • F5, pare o Play e abra no Explorer a pasta mostrada no log do GameBootstrap ("Save em: …"): o profile.json tem "version":1 e os três Cores ("Terra","Ice","Magma") em unlockedCoreIds. Depois de dois saves, existe também o profile.json.bak.
  • Apertar Play direto na cena Gameplay também funciona (serviços prontos, Esc pausa), sem passar pela Boot.
  • F9 apaga o progresso: no Play seguinte, a abertura volta a ser a nº 1.

Problemas comuns:

  • NullReferenceException em Services.Clock ou Services.Save: algum script usou um serviço no Awake de um objeto que foi criado dentro do GameBootstrap.Initialize, antes do Services.Register. Mova esse uso para o Start (regra do Passo 10).
  • The type or namespace name 'UI' does not exist in the namespace 'UnityEngine': faltou adicionar UnityEngine.UI ao asmdef (Passo 1).
  • The type or namespace name 'InputSystem' could not be found: falta Unity.InputSystem no asmdef (Fase 0, Passo 5), ou o Active Input Handling não está em Input System Package (New) (Fase 0, Passo 9).
  • O Console mostra Scene 'Gameplay' couldn't be loaded because it has not been added to the build settings: a cena não está na Scene List do File > Build Profiles (Fase 0, Passo 8), ou o nome do arquivo não é idêntico ao do enum GameScene.
  • A abertura é sempre a nº 1: o save não está sendo gravado. Procure no Console por [SaveService] Falha ao salvar; em geral é antivírus ou a pasta sincronizada por nuvem bloqueando o arquivo.
  • O planeta não congela na pausa: o Animator está com Update Mode = Unscaled Time. Deixe em Normal: tudo o que é gameplay usa o tempo normal, e só a UI de pausa usa o "unscaled".
  • Clicar fora do Game view não pausa o jogo no Editor: é de propósito (Passo 7). Para testar a pausa automática, rode o build Web e troque de aba.

Próxima fase: Fase 2 — Planeta, Quadrants e câmera. Ela coloca o planeta de verdade na cena, com HP, a Pixel Perfect Camera em 320×180 e os 4 Quadrants.


Fase 2 — Planeta, Quadrants e câmera​

Objetivo desta fase: a cena Gameplay mostra o fundo de espaço, o grid isométrico e o planeta girando no centro, flutuando acima do grid com a sua sombra, nítido em qualquer resolução, com HP, dano (já com a fórmula de defesa) e regeneração. O código já sabe converter um ponto do chão (onde a regra acontece) para a vista (onde ele aparece) e de volta, e sabe em qual Quadrant cada ponto do chão está. A câmera desliza quando a gaveta de upgrades abrir. Ainda não há Satellites nem inimigos.

Conceitos novos:

  • Pixel Perfect Camera: componente do URP que faz a câmera mostrar exatamente 320×180 pixels do jogo e ampliar por um número inteiro (6× em 1080p). Sem ele, a pixel art fica com pixels de tamanhos diferentes e "tremendo" ao se mover.
  • Upscale Render Texture: o jogo é desenhado primeiro numa imagem de 320×180 e só depois ampliado para a tela. Assim, até o que gira (o laser da Mothership) ou se move meio pixel continua preso à grade de pixels.
  • Unidade de mundo (u): com PPU 16, 1 u = 16 px. O planeta tem 2 u de diâmetro e a tela mostra 20 × 11,25 u (Seção 4).
  • Sprite animado sem Animator: para loops simples de frames (planeta, estrelas e, depois, inimigos e Satellites), um componente pequeno que troca o sprite a cada 1/fps segundos é mais leve e mais fácil de reiniciar do que um Animator com um controller por objeto. Com 150 inimigos na tela, isso faz diferença.
  • Gizmos: desenhos que só aparecem na janela Scene (e no Game, se ligados), úteis para ver raios e eixos invisíveis durante o desenvolvimento.
  • Chão e vista (projeção isométrica): a regra do jogo usa coordenadas do chão, um plano 2D comum, onde a órbita é um círculo. A câmera mostra esse chão na diagonal. Para cada objeto, o jogo guarda a posição no chão e calcula a posição na vista com uma conta fixa (IsoProjection). Como a conta é linear, ela não muda nada da regra: só o desenho.
  • Objeto com duas partes: a raiz do objeto (inimigo, Satellite, planeta) fica no chão e não tem sprite; o desenho fica num filho View, que o componente IsoView move para a posição projetada a cada frame. Os scripts de regra das próximas fases são quase os mesmos do 8-Bit Armageddon, porque continuam mexendo só na raiz.
  • Ordenação pelo eixo Y: na isométrica, o que está mais acima na tela está mais longe. O Renderer 2D do URP pode ordenar os sprites de uma mesma Sorting Layer por um eixo (Transparency Sort Axis): com o eixo Y, quem está mais acima é desenhado antes, e um Satellite passa por trás do planeta sem nenhum código.
  • Projeção pela hierarquia: um objeto filho de um pai girado 45° e achatado pela metade na vertical aparece exatamente como o chão isométrico. É assim que o preenchimento dos Quadrants e os eixos ficam deitados no grid (IsoFloor) sem nenhuma conta por pixel.

Passo 1 — Medidas do mundo: WorldLayout​

// Caminho: Assets/_Project/Scripts/World/WorldLayout.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: raio do planeta, raio da órbita e distância de spawn são usados por vários scripts
// (Satellites, inimigos, Waves, gizmos). Se cada um tivesse o seu "1.75f", mudar a órbita exigiria
// caçar números mágicos pelo projeto.
// ESTRATÉGIA: classe estática só com constantes e um utilitário. Não guarda estado nem fica em cena.
// Medidas do mundo em unidades (u). 1 u = 16 px (Seções 4 e 6.1). O planeta fica sempre na origem.
public static class WorldLayout
{
public const float PixelsPerUnit = 16f;
public const float PlanetRadius = 1f; // planeta de 32 px = 2 u de diâmetro
public const float OrbitRadius = 1.75f; // órbita dos Satellites (Seção 4.1)
public const float SpawnRadius = 12f; // onde os inimigos nascem (Seção 4.3)

// static readonly em vez de const: const só aceita tipos simples (números, strings), e Vector2 não é um deles.
public static readonly Vector2 PlanetCenter = Vector2.zero;

// Arredonda uma posição para a grade de pixels (1/16 u).
// POR QUE: em pixel art, um objeto parado em "meio pixel" fica tremido/borrado; prender à grade evita isso.
public static float SnapToPixel(float value) => Mathf.Round(value * PixelsPerUnit) / PixelsPerUnit;
}
}

Passo 2 — Os 4 Quadrants: Quadrant​

// Caminho: Assets/_Project/Scripts/World/Quadrant.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: a regra central do combate é "um Satellite só atira no Quadrant onde está".
// Precisamos de um nome para cada Quadrant em vez de números soltos.
// ESTRATÉGIA: enum com valores 0–3 na ordem anti-horária; assim o número do enum × 90° é o ângulo
// onde o Quadrant começa (usado em StartAngle).
// Os 4 Quadrants do CHÃO, centrados no planeta (Seção 4.1), na ordem anti-horária da órbita.
// Os nomes dizem onde cada um aparece na tela isométrica: as 4 pontas do losango do grid.
public enum Quadrant
{
Top = 0, // 0° a 90° (x e y do chão positivos)
Left = 1, // 90° a 180°
Bottom = 2, // 180° a 270°
Right = 3, // 270° a 360°
}

// POR QUE: a pergunta "em qual Quadrant está esta posição/ângulo?" é feita pelo Satellite, pelo
// TargetSelector e pelas ferramentas de teste. A resposta precisa ser idêntica em todos.
// ESTRATÉGIA: classe estática de funções puras (entrada → saída, sem estado). Fácil de testar.
public static class Quadrants
{
// Ângulo em graus, no chão: 0° = eixo x positivo, crescendo no sentido anti-horário (o mesmo da órbita).
// Um ponto exatamente sobre um eixo fica no Quadrant que COMEÇA naquele ângulo (ex.: 90° = Left),
// assim ele pertence a um só Quadrant, como pede a Seção 4.1.
public static Quadrant FromAngle(float degrees)
{
// Mathf.Repeat traz qualquer ângulo (ex.: -30° ou 400°) para o intervalo [0, 360).
float angle = Mathf.Repeat(degrees, 360f);
// Dividir por 90 e arredondar para baixo dá 0, 1, 2 ou 3. O Min(3, ...) é uma proteção
// contra erro de arredondamento muito perto de 360°.
return (Quadrant)Mathf.Min(3, Mathf.FloorToInt(angle / 90f));
}

// Converte uma posição do CHÃO em Quadrant, calculando o ângulo dela em volta do planeta.
// Atenção: uma posição da vista (ex.: o mouse) precisa passar antes por IsoProjection.ViewToGround.
public static Quadrant FromPosition(Vector2 position)
{
Vector2 offset = position - WorldLayout.PlanetCenter;
// Atan2(y, x) devolve o ângulo do vetor em radianos (-π a π); Rad2Deg converte para graus.
return FromAngle(Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg);
}

// Ângulo (em graus) onde o Quadrant começa (Top = 0°, Left = 90°...). Utilitário para quem
// precisar posicionar ou desenhar algo alinhado a um Quadrant.
public static float StartAngle(Quadrant quadrant) => (int)quadrant * 90f;
}
}

Passo 3 — Do chão para a tela: IsoProjection​

Esta é a peça nova que separa este jogo do 8-Bit Armageddon. A regra continua num plano 2D, o chão (Seção 6.1); a câmera mostra esse chão na diagonal. Toda conversão entre os dois passa por esta classe.

// Caminho: Assets/_Project/Scripts/World/IsoProjection.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: a regra do jogo acontece num plano 2D (o "chão"): órbita, alcance, Quadrants e Spawn Sectors
// são círculos e retas de verdade (Seção 4). A câmera isométrica só muda ONDE cada coisa aparece na tela.
// Se cada script fizesse a sua conta de projeção, um erro num deles desalinharia o desenho da regra.
// ESTRATÉGIA: classe estática com a conta num lugar só. "Chão" = coordenadas do jogo (u);
// "vista" = onde o sprite é desenhado. Projeção 2:1 (Seção 6.1): 1 u no eixo x do chão vira
// (+0,75; +0,375) u na vista, e 1 u no eixo y vira (−0,75; +0,375). Com 16 px por unidade, isso dá
// 12 px para o lado e 6 px para cima: o losango 2:1 clássico do isométrico.
public static class IsoProjection
{
public const float AxisX = 0.75f; // 12 px: quanto 1 u do chão anda na horizontal da tela
public const float AxisY = 0.375f; // 6 px: quanto anda na vertical (a metade: isso é o "2:1")
public const float FloorDrop = 1f; // 16 px: o grid fica 1 u abaixo do plano de voo (Seção 6.1)

// Chão → vista, no plano de voo (planeta, Satellites, inimigos, projéteis).
// Estratégia: x − y decide o lado (direita/esquerda); x + y decide a altura (cima/baixo).
public static Vector2 GroundToView(Vector2 ground)
{
return new Vector2((ground.x - ground.y) * AxisX, (ground.x + ground.y) * AxisY);
}

// Chão → vista, no chão do grid (sombras, preenchimento dos Quadrants): o mesmo ponto, 1 u mais baixo.
public static Vector2 GroundToFloor(Vector2 ground)
{
return GroundToView(ground) + Vector2.down * FloorDrop;
}

// Vista → chão, no plano de voo: o caminho de volta. Usado pelas ferramentas de teste, para criar
// coisas embaixo do mouse.
// Estratégia: da vista saem direto a = x − y e b = x + y; x é a média de a e b, e y é a meia-diferença.
public static Vector2 ViewToGround(Vector2 view)
{
float a = view.x / AxisX; // x − y
float b = view.y / AxisY; // x + y
return new Vector2((a + b) * 0.5f, (b - a) * 0.5f);
}

// Atalho: uma posição da vista já presa à grade de pixels (ver WorldLayout.SnapToPixel), pronta para
// ir num transform.position.
public static Vector3 Snap(Vector2 view)
{
return new Vector3(WorldLayout.SnapToPixel(view.x), WorldLayout.SnapToPixel(view.y), 0f);
}
}
}

Por que a regra não muda? A projeção é linear: ela leva retas em retas e o centro do planeta (0, 0) em (0, 0). Um inimigo que anda em linha reta até o planeta no chão também anda em linha reta até ele na tela, e a ordem "quem está mais perto" não importa para a projeção, porque o TargetSelector mede distâncias no chão. Por isso Satellites, Waves e economia são os mesmos do 8-Bit Armageddon.

Passo 4 — Sprites animados: SpriteAnimator​

// Caminho: Assets/_Project/Scripts/World/SpriteAnimator.cs
using System;
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: planeta, estrelas, inimigos e explosões são loops simples de frames. O Animator da Unity
// resolve isso, mas exige um controller por objeto e custa caro com 150 inimigos na tela.
// ESTRATÉGIA: um componente minúsculo que avança um relógio próprio e escolhe o frame = tempo × fps.
// Troca o sprite do SpriteRenderer a uma taxa fixa (os fps da Seção 6.4).
// Usa o tempo normal (Time.deltaTime): pausa junto com o jogo.
// [RequireComponent]: ao adicionar este script num objeto, a Unity adiciona um SpriteRenderer junto
// (e não deixa removê-lo). Garante que o GetComponent do Awake nunca volte null.
[RequireComponent(typeof(SpriteRenderer))]
public sealed class SpriteAnimator : MonoBehaviour
{
[SerializeField] private Sprite[] _frames;
[SerializeField] private float _fps = 8f;
[SerializeField] private bool _loop = true; // false = toca uma vez e para (explosões)
[SerializeField] private bool _randomStartFrame; // cada inimigo começa num frame diferente

private SpriteRenderer _renderer;
private float _time; // há quanto tempo a animação está tocando (em segundos de jogo)

public bool IsPlaying { get; private set; }

// Só para animações "uma vez" (explosões): avisa quando o último frame terminou.
public event Action Finished;

// Cache do componente: GetComponent procura entre os componentes do objeto e tem custo.
// Fazer uma vez no Awake e guardar num campo é muito mais barato que chamar todo frame.
private void Awake()
{
_renderer = GetComponent<SpriteRenderer>();
}

// OnEnable: chamado sempre que o objeto é ativado (inclusive ao ser reaproveitado de um pool
// com SetActive(true)). Por isso a animação recomeça aqui, e não só no Start.
private void OnEnable()
{
Play();
}

// Troca a animação por código (ex.: o SpaceBackground cria estrelas sem Inspector; o inimigo
// recebe os frames da sua definição). Já começa a tocar.
public void SetFrames(Sprite[] frames, float fps, bool loop)
{
_frames = frames;
_fps = fps;
_loop = loop;
Play();
}

// (Re)começa do início, ou de um frame sorteado. Se não houver frames, fica parado sem erro.
public void Play()
{
IsPlaying = _frames != null && _frames.Length > 0 && _fps > 0f;
if (!IsPlaying) return;
// UnityEngine.Random por extenso: "Random" sozinho seria ambíguo com System.Random (using System).
_time = _randomStartFrame ? UnityEngine.Random.Range(0, _frames.Length) / _fps : 0f;
Show(Mathf.FloorToInt(_time * _fps));
}

// Avança o relógio e mostra o frame correspondente.
private void Update()
{
if (!IsPlaying) return;

// Time.deltaTime = segundos desde o último frame (0 quando o jogo está pausado).
_time += Time.deltaTime;
int frame = Mathf.FloorToInt(_time * _fps);

if (!_loop && frame >= _frames.Length)
{
Show(_frames.Length - 1); // para no último frame
IsPlaying = false;
Finished?.Invoke();
return;
}

// % (resto da divisão) faz o índice dar a volta: 0,1,2,3,0,1,...
Show(frame % _frames.Length);
}

// Troca o sprite exibido. Único lugar que mexe no SpriteRenderer.
private void Show(int frame)
{
_renderer.sprite = _frames[frame];
}
}
}

Passo 5 — O desenho separado da regra: IsoView e ShadowSprite​

Todo objeto do mundo passa a ter duas partes: a raiz, nas coordenadas do chão, com os scripts de regra e sem sprite; e um filho View, com o SpriteRenderer, o SpriteAnimator e o IsoView. Os scripts de regra das próximas fases continuam movendo só a raiz.

// Caminho: Assets/_Project/Scripts/World/ShadowSprite.cs
using System.Collections.Generic;
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: na vista isométrica é difícil saber "onde no chão" está uma coisa que voa: dois inimigos em
// direções diferentes podem aparecer na mesma altura da tela. A sombra no grid mostra o ponto exato do
// chão, e portanto o Quadrant (Seção 6.2).
// ESTRATÉGIA: como o PixelSprite (Fase 3), cria o sprite por código na primeira vez que alguém pede:
// uma elipse 2:1 preta, pixel a pixel (a transparência vem do SpriteRenderer.color). Guarda uma por
// largura num dicionário, porque o planeta, o Brute e o Swarmer têm tamanhos diferentes e cada tamanho
// só precisa ser criado uma vez.
public static class ShadowSprite
{
private static readonly Dictionary<int, Sprite> _byWidth = new Dictionary<int, Sprite>();

// Sombra para um objeto de "radius" u no chão. Um círculo de raio r no chão vira uma elipse de
// meia-largura r × AxisX × √2 na vista (Seção 4): é essa a largura da sombra.
public static Sprite ForRadius(float radius)
{
int width = Mathf.Max(4, Mathf.RoundToInt(radius * 2f * IsoProjection.AxisX * Mathf.Sqrt(2f) * WorldLayout.PixelsPerUnit));
// TryGetValue: procura no dicionário e, se achar, devolve true e o valor no "out".
if (_byWidth.TryGetValue(width, out var cached)) return cached;

int height = Mathf.Max(2, width / 2); // 2:1, como tudo que está deitado no chão
var texture = new Texture2D(width, height) { filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp };
var pixels = new Color32[width * height];
float rx = width * 0.5f, ry = height * 0.5f;
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
// Equação da elipse: dentro se (dx/rx)² + (dy/ry)² ≤ 1. +0,5 = centro do pixel.
float dx = (x + 0.5f - rx) / rx, dy = (y + 0.5f - ry) / ry;
pixels[y * width + x] = dx * dx + dy * dy <= 1f ? new Color32(0, 0, 0, 255) : new Color32(0, 0, 0, 0);
}
}
texture.SetPixels32(pixels);
texture.Apply();
var sprite = Sprite.Create(texture, new Rect(0, 0, width, height), new Vector2(0.5f, 0.5f), WorldLayout.PixelsPerUnit);
_byWidth[width] = sprite;
return sprite;
}

// Mesma limpeza de estáticos do GameBootstrap.ResetStatics: sem recarregar o domínio entre Plays,
// o dicionário guardaria sprites de texturas que a Unity já destruiu.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
_byWidth.Clear();
}
}
}
// Caminho: Assets/_Project/Scripts/World/IsoView.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: todo objeto do jogo tem duas posições: onde ele ESTÁ (no chão, que é o que a regra usa) e
// onde ele APARECE (na vista isométrica). Misturar as duas no mesmo transform obrigaria cada script de
// regra (Enemy, Satellite, Projectile...) a fazer a conta de projeção, e um esquecimento quebraria o combate.
// ESTRATÉGIA: o objeto raiz (o pai) fica nas coordenadas do chão e não tem sprite. O desenho fica num
// filho "View" com este componente, que a cada frame se coloca na posição projetada do pai. Os scripts
// de regra continuam movendo só o pai.
// Opcional: cria uma sombra no grid, embaixo do objeto (Seção 6.2).
// A ordem de desenho vem da Sorting Layer "World", ordenada pelo eixo Y (Fase 2, Passo 13): quem está
// mais acima na tela está mais longe e é desenhado antes.
public sealed class IsoView : MonoBehaviour
{
[SerializeField] private float _shadowRadius; // u no chão; 0 = sem sombra
[SerializeField] private Color _shadowColor = new Color(0.02f, 0.01f, 0.06f, 0.45f);
[SerializeField] private string _shadowSortingLayer = "Floor";

private SpriteRenderer _shadow;

// Cria a sombra, se o prefab pediu uma.
private void Awake()
{
if (_shadowRadius > 0f) SetShadowRadius(_shadowRadius);
}

// Liga a sombra com o tamanho pedido (ou só troca o tamanho). O Enemy chama ao nascer, porque um
// mesmo prefab vira Grunt, Swarmer ou Brute.
// A sombra é IRMÃ do View (filha do mesmo pai): liga e desliga junto com o objeto no pooling.
public void SetShadowRadius(float radius)
{
_shadowRadius = radius;
if (_shadow == null)
{
var go = new GameObject("Shadow");
go.transform.SetParent(transform.parent, false);
_shadow = go.AddComponent<SpriteRenderer>();
_shadow.color = _shadowColor;
_shadow.sortingLayerName = _shadowSortingLayer;
}
_shadow.sprite = ShadowSprite.ForRadius(radius);
Place();
}

// LateUpdate: depois de todos os Update, quando o pai já se moveu neste frame.
private void LateUpdate()
{
Place();
}

// Projeta a posição do pai (chão) e coloca o desenho e a sombra.
// "position" é a posição no MUNDO: o filho ignora onde o pai está e vai direto para a vista.
private void Place()
{
Vector2 ground = transform.parent.position;
transform.position = IsoProjection.Snap(IsoProjection.GroundToView(ground));
if (_shadow != null) _shadow.transform.position = IsoProjection.Snap(IsoProjection.GroundToFloor(ground));
}
}
}

E a raiz, que está no chão, não aparece em algum lugar estranho da tela? Ela não tem sprite, então não aparece. Ela só existe para guardar a posição da regra. Na janela Scene, selecionar um inimigo mostra o gizmo de transform dele num ponto que não coincide com o desenho: é o ponto do chão. Para mexer num objeto à mão na Scene, mova a raiz e veja o View ir junto no Play.

Passo 6 — Vida do planeta: PlanetHealth​

Os valores de HP, regeneração e defesa vêm dos Stats, que só existem na Fase 6. Por enquanto, eles ficam no Inspector com os valores base da Seção 4.2, e os métodos Set… já existem para a Fase 6 chamar.

// Caminho: Assets/_Project/Scripts/Planet/PlanetHealth.cs
using System;
using UnityEngine;

namespace Armageddon.Planets
{
// POR QUE: o planeta é o "jogador" deste jogo: quando o HP zera, a run acaba. Dano, defesa e
// regeneração precisam de uma regra única, que a HUD, os inimigos e a run consultam.
// ESTRATÉGIA: um componente só de "vida", separado do Planet (visual/posição). Guarda os números,
// aplica a fórmula da Seção 4.2 e AVISA por eventos (Damaged, HealthChanged, Died) em vez de
// chamar a HUD ou o RunController diretamente: quem se interessa se inscreve.
// Os valores vêm do Inspector por enquanto; a Fase 6 passa a chamar os métodos Set….
public sealed class PlanetHealth : MonoBehaviour
{
public const float MaxDefenseRelative = 0.75f; // teto do Stat (Seção 4.2)

[SerializeField] private float _maxHitpoints = 100f;
[SerializeField] private float _regeneration; // HP por segundo
[SerializeField] private float _defenseAbsolute; // subtraído de cada golpe
// [Range] mostra um slider no Inspector e impede valores fora do intervalo ali.
[SerializeField, Range(0f, MaxDefenseRelative)] private float _defenseRelative; // % do golpe ignorada

public float Current { get; private set; }
public float Max => _maxHitpoints;
public bool IsDead { get; private set; }

public event Action<float> Damaged; // recebe o dano FINAL, depois da defesa
public event Action HealthChanged; // qualquer mudança de HP (dano, regeneração, upgrade)
public event Action Died;

// Começa a run com HP cheio.
private void Awake()
{
Current = _maxHitpoints;
}

// Regeneração contínua: soma "HP por segundo × segundos deste frame".
private void Update()
{
if (IsDead || _regeneration <= 0f || Current >= _maxHitpoints) return;
Current = Mathf.Min(_maxHitpoints, Current + _regeneration * Time.deltaTime);
HealthChanged?.Invoke();
}

// Fórmula da Seção 4.2: max(1, (danoBruto − DefenseAbsolute) × (1 − DefenseRelative)).
// "public static": não depende de um planeta específico, então a gaveta de upgrades pode usar
// a mesma fórmula para mostrar previsões, e fica fácil de testar.
public static float ComputeDamage(float raw, float defenseAbsolute, float defenseRelative)
{
return Mathf.Max(1f, (raw - defenseAbsolute) * (1f - defenseRelative));
}

// Chamado por quem acerta o planeta (inimigos, laser da Mothership) com o dano BRUTO.
public void TakeDamage(float raw)
{
if (IsDead || raw <= 0f) return;

float damage = ComputeDamage(raw, _defenseAbsolute, _defenseRelative);
Current = Mathf.Max(0f, Current - damage);
Damaged?.Invoke(damage);
HealthChanged?.Invoke();

if (Current <= 0f)
{
IsDead = true;
Died?.Invoke(); // o RunController (Fase 8) encerra a run: não existe Revive (Seção 4.6)
}
}

// Upgrade de Hitpoints: o HP atual sobe na mesma quantidade que o máximo (Seção 4.2).
public void SetMaxHitpoints(float value)
{
float gained = value - _maxHitpoints;
_maxHitpoints = Mathf.Max(1f, value);
if (gained > 0f && !IsDead) Current += gained;
Current = Mathf.Min(Current, _maxHitpoints);
HealthChanged?.Invoke();
}

// Chamado quando o Stat de regeneração muda (Fase 6).
public void SetRegeneration(float hpPerSecond)
{
_regeneration = Mathf.Max(0f, hpPerSecond);
}

// Chamado quando os Stats de defesa mudam (Fase 6). O Clamp respeita o teto de 75%.
public void SetDefense(float absolute, float relative)
{
_defenseAbsolute = Mathf.Max(0f, absolute);
_defenseRelative = Mathf.Clamp(relative, 0f, MaxDefenseRelative);
}
}
}

Passo 7 — O planeta: Planet​

// Caminho: Assets/_Project/Scripts/Planet/Planet.cs
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Planets
{
// POR QUE: outros sistemas precisam de "o planeta" como um ponto de referência (onde está, qual o
// raio, qual a vida). Este é o componente que eles procuram.
// ESTRATÉGIA: uma "fachada" fina: expõe o PlanetHealth e as medidas do WorldLayout, e garante que o
// objeto fica na origem do chão. A lógica de vida continua no PlanetHealth; o desenho fica no filho
// View (IsoView); a skin é trocada pela Core Select na Fase 9.
[RequireComponent(typeof(PlanetHealth))]
public sealed class Planet : MonoBehaviour
{
public PlanetHealth Health { get; private set; }
public Vector2 Center => WorldLayout.PlanetCenter; // "=>" aqui é uma propriedade só de leitura calculada
public float Radius => WorldLayout.PlanetRadius;

// Guarda o PlanetHealth (cache) e prende o planeta na origem, mesmo que alguém o arraste na cena.
private void Awake()
{
Health = GetComponent<PlanetHealth>();
transform.position = WorldLayout.PlanetCenter;
}

// OnDrawGizmos: a Unity chama no Editor para desenhar ajudas visuais na janela Scene (não entra no jogo).
// Na janela Scene: o raio do planeta, a órbita dos Satellites, o alcance base (4 u) e os eixos dos
// Quadrants, já PROJETADOS no grid (elipses e diagonais), no lugar onde o jogador os vê.
private void OnDrawGizmos()
{
Gizmos.color = Color.green;
DrawGroundCircle(WorldLayout.PlanetRadius);
Gizmos.color = Color.gray;
DrawGroundCircle(WorldLayout.OrbitRadius);
Gizmos.color = new Color(1f, 0.6f, 0.2f);
DrawGroundCircle(4f);
Gizmos.color = Color.white;
float axis = WorldLayout.SpawnRadius;
DrawGroundLine(Vector2.left * axis, Vector2.right * axis);
DrawGroundLine(Vector2.down * axis, Vector2.up * axis);
}

// Um círculo do chão vira elipse na vista: 48 segmentos de reta entre pontos projetados.
private static void DrawGroundCircle(float radius)
{
const int segments = 48;
Vector2 previous = IsoProjection.GroundToFloor(new Vector2(radius, 0f));
for (int i = 1; i <= segments; i++)
{
float angle = i * 2f * Mathf.PI / segments;
Vector2 next = IsoProjection.GroundToFloor(new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius);
Gizmos.DrawLine(previous, next); // Vector2 vira Vector3 sozinho (z = 0)
previous = next;
}
}

// Uma reta do chão continua reta na vista: basta projetar as duas pontas.
private static void DrawGroundLine(Vector2 from, Vector2 to)
{
Gizmos.DrawLine(IsoProjection.GroundToFloor(from), IsoProjection.GroundToFloor(to));
}
}
}

Passo 8 — O fundo de espaço: SpaceBackground​

// Caminho: Assets/_Project/Scripts/World/SpaceBackground.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: o fundo de espaço precisa cobrir qualquer tela (16:9, 21:9, 4:3) sem que alguém
// monte tiles à mão na cena, e sem parecer repetido. Ele é da TELA, não do chão: não passa pela
// projeção isométrica (Seção 6.2).
// ESTRATÉGIA: no Start, calcula quantos tiles cabem na visão da câmera (mais uma margem) e cria um
// SpriteRenderer por tile, sorteando qual dos 4 desenhos usar. O sorteio usa uma semente fixa
// (_seed): o fundo é sempre igual, o que ajuda a comparar screenshots e testar.
// Cobre também uma margem: a câmera desce com a gaveta aberta, e telas mais largas que 16:9 mostram mais espaço.
public sealed class SpaceBackground : MonoBehaviour
{
private const float TileSize = 2f; // 32 px = 2 u

[SerializeField] private Sprite[] _tiles; // as 4 células de SPR_Background_Space
[SerializeField] private Sprite[] _twinkleFrames; // as 4 células de SPR_Background_StarTwinkle
[SerializeField] private float _twinkleFps = 4f;
[SerializeField] private int _twinkleCount = 8;
[SerializeField] private int _seed = 7; // mesma semente = mesmo fundo toda vez
[SerializeField] private float _margin = 3f; // unidades extras além da borda da câmera
[SerializeField] private string _sortingLayer = "Background";

// Start (e não Awake) porque depende da câmera principal já estar configurada na cena.
private void Start()
{
Build(Camera.main);
}

// Cria a grade de tiles e as estrelas piscando.
private void Build(Camera view)
{
// System.Random com semente: sequência de sorteios repetível (o UnityEngine.Random é global e
// seria afetado por outros sistemas).
var random = new System.Random(_seed);
// Numa câmera ortográfica, orthographicSize é METADE da altura visível em unidades.
float halfHeight = view.orthographicSize + _margin;
float halfWidth = view.orthographicSize * view.aspect + _margin;
int columns = Mathf.CeilToInt(halfWidth / TileSize);
int rows = Mathf.CeilToInt(halfHeight / TileSize);

for (int x = -columns; x < columns; x++)
{
for (int y = -rows; y < rows; y++)
{
// O pivô é o centro da célula: o centro do tile fica no meio de cada quadrado de 2 u.
var position = new Vector2(x * TileSize + TileSize * 0.5f, y * TileSize + TileSize * 0.5f);
CreateSprite("Tile", _tiles[random.Next(_tiles.Length)], position, 0);
}
}

for (int i = 0; i < _twinkleCount; i++)
{
// NextDouble()*2-1 = número entre -1 e 1; vezes a metade da área = posição aleatória na tela.
var position = new Vector2(
WorldLayout.SnapToPixel((float)(random.NextDouble() * 2 - 1) * halfWidth),
WorldLayout.SnapToPixel((float)(random.NextDouble() * 2 - 1) * halfHeight));
var star = CreateSprite("Twinkle", _twinkleFrames[0], position, 1);
star.gameObject.AddComponent<SpriteAnimator>().SetFrames(_twinkleFrames, _twinkleFps, true);
}
}

// Fábrica de um sprite filho deste objeto. Centraliza as configurações repetidas (pai, layer, ordem).
// "order" dentro da mesma Sorting Layer: maior é desenhado por cima (estrelas = 1, tiles = 0).
private SpriteRenderer CreateSprite(string name, Sprite sprite, Vector2 position, int order)
{
var go = new GameObject(name);
go.transform.SetParent(transform, false);
go.transform.localPosition = position;
var renderer = go.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = _sortingLayer;
renderer.sortingOrder = order;
return renderer;
}
}
}

As estrelas piscam em sincronia, de propósito: o fundo não deve chamar atenção. A opção Random Start Frame do SpriteAnimator existe para os inimigos (Fase 4), que ficam mais naturais cada um num frame diferente.

Passo 9 — O grid isométrico: IsoGrid​

// Caminho: Assets/_Project/Scripts/World/IsoGrid.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: o planeta fica no centro de um grid isométrico (Seção 6.1). O grid é só referência visual:
// dá profundidade ao chão e marca, pelas linhas centrais, onde um Quadrant termina e o outro começa.
// ESTRATÉGIA: como o SpaceBackground, monta tudo por código no Start: um SpriteRenderer por tile, numa
// grade quadrada no CHÃO, cada um projetado com GroundToFloor. O lado de um tile no chão é 4/3 u,
// porque o tile da arte tem 32 px de largura (2 × 12 px × 4/3 = 32 px). Os tiles das bordas ficam mais
// transparentes, para o grid sumir no espaço em vez de terminar num corte seco.
// A Sorting Layer "Grid" é ordenada pelo eixo Y (Passo 13): os tiles de trás ficam atrás sem nenhuma conta.
public sealed class IsoGrid : MonoBehaviour
{
public const float CellSize = 4f / 3f; // lado de um tile, em u do chão (Seção 6.2)
private const float TileTopOffset = 3f / 16f; // a face de cima do tile fica 3 px acima do centro da célula (Seção 6.4)

[SerializeField] private Sprite[] _tiles; // as 4 células de SPR_Grid_Floor (a 3 é a rara)
[SerializeField, Range(0f, 1f)] private float _rareChance = 0.06f;
[SerializeField] private int _halfCells = 10; // tiles do centro até a borda, em cada eixo
[SerializeField] private int _fadeCells = 3; // quantos tiles da borda vão sumindo
[SerializeField, Range(0f, 1f)] private float _alpha = 0.45f; // o grid é discreto: não compete com os inimigos
[SerializeField] private int _seed = 7; // mesma semente = mesmo grid toda vez (ver SpaceBackground)
[SerializeField] private string _sortingLayer = "Grid";

// Cria os tiles uma vez só.
private void Start()
{
var random = new System.Random(_seed);
for (int i = -_halfCells; i < _halfCells; i++)
{
for (int j = -_halfCells; j < _halfCells; j++)
{
// Centro do tile (i, j) no chão. As linhas x = 0 e y = 0 caem nas bordas dos tiles:
// são os eixos dos Quadrants (Seção 6.1).
var ground = new Vector2((i + 0.5f) * CellSize, (j + 0.5f) * CellSize);
// O pivô do sprite é o centro da célula de 32×32, mas o centro do losango de cima fica 3 px
// mais acima: descer o sprite 3 px põe o losango exatamente no ponto do chão.
Vector2 view = IsoProjection.GroundToFloor(ground) - Vector2.up * TileTopOffset;

int sprite = random.NextDouble() < _rareChance ? 3 : random.Next(3); // Next(3) = 0, 1 ou 2
var renderer = CreateTile(_tiles[Mathf.Min(sprite, _tiles.Length - 1)], view);

// "Anel" do tile: a distância (em tiles) até o centro, na métrica do quadrado. Os 3 últimos anéis somem aos poucos.
float ring = Mathf.Max(Mathf.Abs(i + 0.5f), Mathf.Abs(j + 0.5f));
float fade = ring < _halfCells - _fadeCells ? 1f : Mathf.Max(0.15f, (_halfCells - ring) / (_fadeCells + 0.5f));
renderer.color = new Color(1f, 1f, 1f, _alpha * fade);
}
}
}

// Fábrica de um tile filho deste objeto (ver SpaceBackground.CreateSprite).
private SpriteRenderer CreateTile(Sprite sprite, Vector2 position)
{
var go = new GameObject("Tile");
go.transform.SetParent(transform, false);
go.transform.position = IsoProjection.Snap(position);
var renderer = go.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = _sortingLayer;
return renderer;
}
}
}

Passo 10 — O chão deitado: IsoFloor​

O preenchimento dos Quadrants e os eixos (Fase 3) são desenhados como num jogo top-down: um quarto de círculo e duas retas, em coordenadas do chão. Em vez de reescrever esse desenho em isométrico, eles moram dentro de um objeto que deita tudo o que estiver dentro dele.

// Caminho: Assets/_Project/Scripts/World/IsoFloor.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: algumas coisas não "voam": ficam deitadas no chão do grid (o preenchimento dos Quadrants e os
// eixos, Fase 3). Elas precisam aparecer achatadas como o chão isométrico: um círculo vira elipse e um
// quarto de círculo vira uma ponta do losango.
// ESTRATÉGIA: a própria hierarquia da Unity faz a projeção. Este objeto fica no centro do chão (FloorDrop
// abaixo do planeta) com escala (1; 0,5), que achata tudo pela metade na vertical. O filho "Plane" é
// girado 45° e ampliado √2 × 0,75. Girar e depois achatar é exatamente a projeção 2:1 da IsoProjection:
// um ponto (x, y) colocado como filho do Plane aparece em GroundToFloor(x, y).
// Conta, para conferir: girar (x, y) em 45° dá ((x − y)/√2, (x + y)/√2); × √2 × 0,75 dá
// ((x − y) × 0,75, (x + y) × 0,75); achatar o y pela metade dá ((x − y) × 0,75, (x + y) × 0,375). ✓
public sealed class IsoFloor : MonoBehaviour
{
[SerializeField] private Transform _plane; // o filho "Plane": pai de tudo que fica deitado no chão

public Transform Plane => _plane;

// Acerta posição, rotação e escala dos dois objetos no começo, mesmo que alguém tenha mexido no Inspector.
private void Awake()
{
transform.position = Vector2.down * IsoProjection.FloorDrop;
transform.rotation = Quaternion.identity;
transform.localScale = new Vector3(1f, 0.5f, 1f);
if (_plane == null) return;
_plane.localPosition = Vector3.zero;
_plane.localRotation = Quaternion.Euler(0f, 0f, 45f);
float scale = IsoProjection.AxisX * Mathf.Sqrt(2f); // ≈ 1,06
_plane.localScale = new Vector3(scale, scale, 1f);
}
}
}

Isso não deforma os pixels? Deforma, e é o que queremos: o preenchimento é um desenho do chão, e o chão é visto achatado. Com a Upscale Render Texture da Pixel Perfect Camera (Passo 13), o resultado ainda é desenhado na grade de 320×180, então a borda da elipse sai em "escadinha" de pixel, como o resto da arte. Sprites que ficam de pé (planeta, inimigos, Satellites) nunca entram aqui: eles usam o IsoView.

Passo 11 — A câmera: CameraRig​

// Caminho: Assets/_Project/Scripts/World/CameraRig.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: quando a gaveta de upgrades abre, ela cobre a parte de baixo da tela. Sem mexer na câmera,
// o planeta ficaria escondido atrás dela.
// ESTRATÉGIA: um componente na própria Main Camera que só controla a posição Y: calcula um alvo
// (aberta/fechada) e desliza suavemente até ele, sempre preso à grade de pixels.
// Fica no mesmo objeto da Camera e do componente Pixel Perfect Camera (URP). O shake entra na Fase 12.
[RequireComponent(typeof(Camera))]
public sealed class CameraRig : MonoBehaviour
{
// Com a gaveta fechada, a câmera fica meio FloorDrop abaixo do planeta: assim o planeta (no plano de
// voo) e o centro do grid (1 u abaixo dele) ficam centrados juntos na tela (Seção 6.1).
private const float RestY = -IsoProjection.FloorDrop * 0.5f;

[SerializeField, Range(0f, 0.8f)] private float _drawerScreenFraction = 0.4f; // quanto da tela a gaveta ocupa
[SerializeField] private float _slideTime = 0.15f; // segundos aproximados do deslize

private Camera _camera;
private float _currentY;
private float _targetY;
private float _velocity; // "memória" de velocidade que o SmoothDamp precisa entre um frame e outro

public bool IsDrawerOpen { get; private set; }

// Cache da Camera e posição inicial (gaveta fechada).
private void Awake()
{
_camera = GetComponent<Camera>();
_currentY = _targetY = RestY;
ApplyPosition(RestY);
}

// Chamado pela UpgradeDrawer (Fase 6) ao abrir/fechar. Só define o alvo; o movimento é no LateUpdate.
// A gaveta cobre a parte de baixo da tela. Para o planeta ficar no centro da área livre,
// a câmera DESCE metade da altura da gaveta (o mundo parece subir).
public void SetDrawerOpen(bool open)
{
IsDrawerOpen = open;
float viewHeight = _camera.orthographicSize * 2f;
_targetY = RestY + (open ? -viewHeight * _drawerScreenFraction * 0.5f : 0f);
}

// LateUpdate: chamado a cada frame DEPOIS de todos os Update. É o lugar padrão para câmeras,
// porque tudo o que se move no frame já se moveu.
private void LateUpdate()
{
// SmoothDamp: aproxima um valor do alvo com aceleração e desaceleração suaves (efeito "mola").
// Tempo "unscaled": a câmera termina de deslizar mesmo se o jogo pausar no meio.
_currentY = Mathf.SmoothDamp(_currentY, _targetY, ref _velocity, _slideTime, Mathf.Infinity, Time.unscaledDeltaTime);
ApplyPosition(_currentY);
}

// Move a câmera para a altura pedida.
private void ApplyPosition(float y)
{
// Posição presa à grade de pixels: evita o mundo "tremer" meio pixel durante o deslize.
// Z = -10: a câmera 2D fica "na frente" dos sprites (que estão em Z = 0) para enxergá-los.
transform.position = new Vector3(0f, WorldLayout.SnapToPixel(y), -10f);
}
}
}

Passo 12 — Novos atalhos de desenvolvimento​

Substitua o DevShortcuts da Fase 1 por esta versão, que acrescenta os testes desta fase (F3, F4 e F6):

// Caminho: Assets/_Project/Scripts/Core/DevShortcuts.cs
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;
using UnityEngine.InputSystem;

namespace Armageddon.Core
{
// POR QUE (versão da Fase 2): agora há planeta e câmera para testar; os atalhos da Fase 1 continuam.
// O QUE MUDOU: F3 (gaveta/câmera), F4 (Quadrant sob o mouse) e F6 (dano).
// ESTRATÉGIA: igual à Fase 1 (ver DevShortcuts da Fase 1). Como os objetos da cena Gameplay não estão
// nos Services, este script os procura com FindAnyObjectByType — lento, mas aceitável numa ferramenta
// de teste que nem vai para o build final. Nunca use isso no código do jogo a cada frame.
// F1 = MainMenu · F2 = Gameplay · F3 = abre/fecha a gaveta (câmera) · F4 = Quadrant sob o mouse
// F5 = libera todos os Planet Cores e Skins · F6 = 10 de dano no planeta · F9 = apaga o progresso.
public sealed class DevShortcuts : MonoBehaviour
{
// Lê as teclas de teste a cada frame (igual à Fase 1, com as teclas novas).
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.f1Key.wasPressedThisFrame) Services.Scenes.Load(GameScene.MainMenu);
if (keyboard.f2Key.wasPressedThisFrame) Services.Scenes.Load(GameScene.Gameplay);

if (keyboard.f3Key.wasPressedThisFrame)
{
var rig = FindAnyObjectByType<CameraRig>();
if (rig != null) rig.SetDrawerOpen(!rig.IsDrawerOpen);
}

if (keyboard.f4Key.wasPressedThisFrame && Mouse.current != null && Camera.main != null)
{
// ScreenToWorldPoint: converte pixels da tela em coordenadas da VISTA. ViewToGround volta
// para o chão, onde os Quadrants são medidos (o mouse "aponta" para o plano de voo).
Vector2 view = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
Vector2 ground = IsoProjection.ViewToGround(view);
Debug.Log($"[Dev] Mouse no chão em {ground} → {Quadrants.FromPosition(ground)}");
}

if (keyboard.f5Key.wasPressedThisFrame)
{
// Testar a Core Select sem cumprir as conquistas. Clear + AddRange troca o conteúdo da lista.
var profile = Services.Save.Profile;
profile.unlockedCoreIds.Clear();
profile.unlockedCoreIds.AddRange(new[] { "Terra", "Ice", "Magma" });
profile.unlockedSkinIds.Clear();
profile.unlockedSkinIds.AddRange(new[]
{
"Terra_Classic", "Terra_Ocean", "Terra_Desert",
"Ice_Classic", "Ice_Aurora", "Ice_Crystal",
"Magma_Classic", "Magma_Obsidian", "Magma_Solar",
});
Services.Save.Save();
Debug.Log("[Dev] Todos os Planet Cores e Skins liberados");
}

var planet = FindAnyObjectByType<Planet>();
if (planet != null && keyboard.f6Key.wasPressedThisFrame)
{
planet.Health.TakeDamage(10f);
Debug.Log($"[Dev] Planeta: {planet.Health.Current:0.#}/{planet.Health.Max} HP");
}

if (keyboard.f9Key.wasPressedThisFrame)
{
Services.Save.ResetProgress();
Debug.Log("[Dev] Progresso apagado.");
}
}
}
}
#endif

Passo 13 — Montar a câmera​

Abra a cena Gameplay e apague o planeta de teste da Fase 1 (o objeto com Animator). O clipe ANIM_Planet_Rotate e o controller que a Unity criou também podem ser apagados: a partir de agora, o planeta usa o SpriteAnimator.

Selecione a Main Camera (ela fica na raiz, pela convenção da Seção 10.1):

  1. Camera: Projection = Orthographic; Background Type = Solid Color; cor #141241 (o azul-marinho do fundo). Assim, se alguma borda aparecer além dos tiles, ela tem a mesma cor.
  2. Add Component → Pixel Perfect Camera (o do URP):
    • Assets Pixels Per Unit: 16
    • Reference Resolution: 320 × 180
    • Crop Frame: None (em telas fora de 16:9, o jogo mostra mais espaço em vez de faixas pretas, Seção 6.1)
    • Grid Snapping: Upscale Render Texture
  3. Add Component → Camera Rig.
  4. Ordem pela profundidade. Na janela Project, abra Assets/_Project/Settings/Rendering/ e selecione o asset do Renderer 2D (o Renderer2D que o template criou). No Inspector, troque Transparency Sort Mode para Custom Axis e Transparency Sort Axis para X 0, Y 1, Z 0. A partir de agora, dentro de uma mesma Sorting Layer, o sprite com Y maior (mais acima na tela) é desenhado antes. É isso que põe um Satellite atrás do planeta na metade de cima da órbita (Seção 6.1) e empilha os tiles do grid na ordem certa.

Passo 14 — Montar o planeta, o fundo, o grid e o chão​

  1. Fundo: em [World], crie um objeto vazio SpaceBackground na posição (0, 0, 0) com o componente SpaceBackground. Em Tiles, arraste as 4 células de Art/Backgrounds/SPR_Background_Space; em Twinkle Frames, as 4 células de SPR_Background_StarTwinkle.

    Para arrastar várias células para uma lista de uma vez: trave o Inspector (cadeado no canto de cima), abra a setinha da spritesheet na janela Project, selecione as células (clique no primeiro, Shift+clique no último) e solte em cima do nome da lista.

  2. Planeta: em [World], crie um objeto vazio Planet na posição (0, 0, 0) com o componente Planet (o PlanetHealth entra junto, pelo RequireComponent). Deixe os valores base: Max Hitpoints 100, o resto 0. Dentro dele, crie um filho vazio View com:
    • Sprite Renderer: Sprite = a célula 0 de SPR_Planet_Terra_Classic_Rotate; Sorting Layer = World.
    • Sprite Animator: Frames = as 32 células de SPR_Planet_Terra_Classic_Rotate; Fps = 4; Loop marcado.
    • Iso View: Shadow Radius = 1 (o raio do planeta).
  3. Arraste o objeto Planet para Assets/_Project/Prefabs/Gameplay/ para criar o prefab Planet.prefab.
  4. Grid: em [World], crie um objeto vazio IsoGrid na posição (0, 0, 0) com o componente IsoGrid. Em Tiles, arraste as 4 células de Art/Grid/SPR_Grid_Floor, na ordem (a célula 3 é a rara).
  5. Chão deitado: em [World], crie um objeto vazio IsoFloor com o componente IsoFloor, e dentro dele um filho vazio Plane. Arraste o Plane para o campo Plane do IsoFloor. Não precisa acertar posição, rotação nem escala: o IsoFloor faz isso no Play (Passo 10). Ele fica vazio por enquanto; o QuadrantView da Fase 3 mora dentro do Plane.

Passo 15 — Commit​

git add . e git commit -m "Phase 2: iso projection, grid, planet, health, quadrants, pixel perfect camera, background".

✅ Checkpoint:

  • Play na Gameplay: o planeta gira perto do centro, flutuando 16 px acima do centro de um grid em losango, com uma sombra elíptica embaixo dele. O grid é discreto (semitransparente) e some aos poucos nas bordas; atrás dele, o fundo de estrelas cobre a tela inteira, com algumas estrelas grandes piscando.
  • Com o Game view em 1920x1080, cada pixel da arte vira um quadrado de 6×6, sem bordas borradas. Trocando para 2560x1080 (21:9) ou 1024x768 (4:3), aparece mais espaço dos lados ou em cima e embaixo, sem faixas pretas e sem esticar.
  • Na janela Scene, os Gizmos mostram, deitados no grid, a elipse do planeta (verde), a da órbita (cinza), a do alcance base (laranja) e os dois eixos dos Quadrants, que correm na diagonal sobre as linhas centrais do grid.
  • F4 com o mouse acima, à esquerda, abaixo e à direita do planeta mostra no Console Top, Left, Bottom e Right, nessa ordem. Com o mouse logo acima e à direita do planeta, o chão mostra x positivo e y perto de 0 (o eixo x do chão aponta para cima e para a direita).
  • F3 faz o mundo deslizar para cima (o planeta sobe até o centro dos 60% de cima da tela); F3 de novo o traz de volta. O movimento é suave e sem tremer.
  • F6 tira 10 de HP (90/100, 80/100…). Com Defense Absolute = 5 no Inspector, cada F6 tira só 5. Com Defense Absolute = 20, tira 1 (o mínimo da fórmula).
  • Com Regeneration = 2, o HP volta a subir 2 por segundo depois do F6. Com Esc (pausa), o planeta, as estrelas e a regeneração param juntos.
  • Ao chegar a 0 HP, o dano para de ser aplicado (a run acaba na Fase 8; não existe Revive).

Problemas comuns:

  • A arte aparece borrada ou com pixels de tamanhos diferentes: a Reference Resolution ou o Assets Pixels Per Unit da Pixel Perfect Camera não bate com 320×180 / 16, ou o Game view está com Scale diferente de 1×. Confira também se os sprites vieram com PPU 16 (preset da Fase 0).
  • Faixas pretas nas bordas: Crop Frame não está em None.
  • O fundo não cobre um canto da tela ao abrir a gaveta: aumente Margin no SpaceBackground. O valor 3 cobre a gaveta de 40% em 16:9.
  • O planeta não aparece, mas o objeto existe: a Sorting Layer do View está abaixo da Background, ou a câmera está com Z maior que 0. O CameraRig põe a câmera em Z = −10: não mova a câmera à mão.
  • O planeta aparece no lugar certo, mas a sombra não: o View não é filho do Planet. O IsoView lê a posição do pai; sem pai, ele não tem chão para projetar.
  • Os tiles do grid aparecem "por cima" uns dos outros, em degraus errados: o Transparency Sort Axis do Renderer 2D não está em (0, 1, 0) (Passo 13), ou os tiles estão numa Sorting Layer diferente de Grid.
  • O grid aparece com frestas entre os tiles: confira se o Pixel Size do slice do SPR_Grid_Floor foi 32 × 32 e se o Pivot ficou Center. O IsoGrid já compensa a espessura do tile (TileTopOffset).
  • 'Planet' is a namespace but is used like a type: algum script usa o namespace antigo Armageddon.Planet. O namespace é Armageddon.Planets (com "s"), justamente para não colidir com a classe Planet.
  • O planeta gira rápido demais: o Fps do SpriteAnimator ficou no padrão (8). O planeta usa 4 (Seção 6.4).

Próxima fase: Fase 3 — Satellites, órbita, Target Priority e projéteis. Ela coloca o primeiro Satellite orbitando e atirando no Quadrant onde está.


Fase 3 — Satellites, órbita, Target Priority e projéteis​

Objetivo desta fase: o primeiro Satellite orbita o planeta e atira projéteis teleguiados nos alvos que estão no Quadrant onde ele está e dentro do Attack Range, escolhendo o alvo pela sua Target Priority (Seção 4.1). O Quadrant coberto aparece na tela e pisca a cada disparo. Os inimigos de verdade chegam na Fase 4; aqui usamos alvos de teste.

Conceitos novos:

  • Interface (ITarget): um "contrato" que diz o que um alvo precisa ter (posição, HP, receber dano), sem dizer o que ele é. Os Satellites atiram em qualquer ITarget: hoje num alvo de teste, na Fase 4 num Enemy, sem mudar uma linha do código de combate.
  • Registro de alvos (TargetRegistry): uma lista dos alvos vivos. Cada alvo se inscreve ao aparecer e sai ao morrer. Procurar alvos numa lista pronta é muito mais barato do que perguntar à física da Unity, a cada disparo, "quem está perto?".
  • Object pooling (ObjectPool): em vez de criar e destruir um projétil a cada tiro (o que gera lixo de memória e engasgos do garbage collector), um conjunto de projéteis é criado uma vez e reaproveitado: "pegar" liga o objeto, "devolver" desliga.
  • Um dono para o tempo da órbita: o SatelliteOrbit move todos os Satellites e chama o Tick de cada um, na ordem. Um só Update controla tudo, o que evita que dois Satellites discordem sobre o ângulo da órbita.
  • Textura gerada por código: o preenchimento do Quadrant (um quarto de círculo do tamanho do Attack Range) é desenhado pixel a pixel na hora, em coordenadas do chão. Como ele mora dentro do IsoFloor/Plane (Fase 2), a hierarquia o deita no grid: na tela, ele vira uma ponta do losango.

Passo 1 — O contrato de alvo e o registro: ITarget, TargetRegistry​

// Caminho: Assets/_Project/Scripts/Combat/ITarget.cs
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: os Satellites precisam atirar em "algo" — hoje num alvo de teste, na Fase 4 num Enemy,
// na Fase 7 na Mothership. Se o código de combate dependesse da classe Enemy, cada tipo novo de
// alvo exigiria mexer nele.
// ESTRATÉGIA: uma interface com só o que o combate precisa saber (posição, tamanho, vida, receber dano).
// Qualquer classe que "implementa" ITarget (class X : MonoBehaviour, ITarget) vira um alvo válido.
public interface ITarget
{
Vector2 Position { get; }
float Radius { get; } // metade do tamanho do sprite, em u: o alcance mede até a BORDA (Seção 4.1)
float CurrentHitpoints { get; } // usado pelas Target Priorities Weakest/Strongest
bool IsAlive { get; }
void TakeDamage(float amount, bool isCritical); // isCritical serve para o feedback visual (número de dano)
}
}
// Caminho: Assets/_Project/Scripts/Combat/TargetRegistry.cs
using System.Collections.Generic;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: a cada disparo, um Satellite precisa da lista de alvos possíveis. Perguntar à física
// ("quem está perto?") ou procurar objetos na cena a cada tiro é caro com 150 inimigos.
// ESTRATÉGIA: uma lista estática e global dos alvos vivos. Cada alvo se registra no OnEnable e sai
// no OnDisable (combina com pooling: ligar/desligar o objeto já entra/sai da lista).
// Os Satellites só leem a lista (IReadOnlyList), nunca a alteram.
public static class TargetRegistry
{
private static readonly List<ITarget> _alive = new List<ITarget>();

// IReadOnlyList: quem recebe pode percorrer e ler, mas não tem Add/Remove.
public static IReadOnlyList<ITarget> Alive => _alive;

// Chamado pelo alvo ao aparecer. O Contains evita registrar duas vezes.
public static void Register(ITarget target)
{
if (!_alive.Contains(target)) _alive.Add(target);
}

// Chamado pelo alvo ao sumir (morreu, saiu da tela, voltou ao pool).
public static void Unregister(ITarget target)
{
_alive.Remove(target);
}

// Com "Enter Play Mode Options" (sem recarregar o domínio), a lista estática sobreviveria entre Plays.
// Mesmo mecanismo do GameBootstrap.ResetStatics (ver GameBootstrap).
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
_alive.Clear();
}
}
}

Passo 2 — Os Stats de combate: SatelliteStats​

Os valores de combate vêm dos Upgrades da Fase 6. Até lá, eles ficam no Inspector do SatelliteOrbit, com os valores base da Seção 4.2. A Fase 6 monta um SatelliteStats a partir dos Stats da run e chama SetStats.

// Caminho: Assets/_Project/Scripts/Combat/SatelliteStats.cs
using System;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: os Satellites precisam de vários números (dano, cadência, crítico, alcance...), que mudam
// com os Upgrades. Passar sete parâmetros soltos para cada método seria confuso.
// ESTRATÉGIA: um "pacote" de dados com os Stats de Offense (Seção 4.2) e a fórmula de dano junto.
// Objeto de dados: campos públicos em camelCase, como o PlayerProfile. [Serializable] faz o pacote
// aparecer inteiro (expansível) no Inspector do SatelliteOrbit, para testar antes da Fase 6.
[Serializable]
public sealed class SatelliteStats
{
public float damage = 10f;
public float attackSpeed = 1f; // disparos por segundo, por Satellite
[Range(0f, 0.8f)] public float criticalChance; // 0 a 0,8 (80%)
public float criticalFactor = 1.5f; // multiplicador do dano crítico
public float attackRange = 4f; // em u, medido do centro do planeta
public float impetus; // fração por u (0,004 = +0,4 % por u)
public float orbitSpeed = 45f; // graus por segundo

// Sorteia o crítico e calcula o dano de UM disparo.
// Fórmula da Seção 4.1: Damage × (1 + Impetus × distânciaAoCentro) × (crítico ? CriticalFactor : 1).
// "out bool isCritical": um segundo valor de retorno — quem chama recebe o dano E se foi crítico.
public float RollDamage(float distanceToCenter, out bool isCritical)
{
// Random.value = número aleatório entre 0 e 1. Menor que a chance → crítico.
isCritical = UnityEngine.Random.value < criticalChance;
float value = damage * (1f + impetus * distanceToCenter);
return isCritical ? value * criticalFactor : value;
}
}
}

Passo 3 — Escolher o alvo: TargetPriority, TargetSelector​

// Caminho: Assets/_Project/Scripts/Combat/TargetSelector.cs
using System.Collections.Generic;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: cada Satellite pode ter uma regra diferente de escolha de alvo (Target Priority).
// ESTRATÉGIA: um enum com as 4 regras; o Satellite guarda qual está usando.
// As 4 Target Priorities (Seção 4.1). Closest é a padrão; todas ficam disponíveis desde o início da run.
public enum TargetPriority
{
Closest, // o mais perto do planeta
Weakest, // o com menos HP atual
Strongest, // o com mais HP atual
Farthest, // o mais longe do planeta (sinergia com Impetus)
}

// POR QUE: a regra "quem é um alvo válido e qual deles é o melhor" é o coração do combate e precisa
// ser a mesma para todos os Satellites.
// ESTRATÉGIA: classe estática com uma função pura: recebe a lista, o Quadrant, o alcance e a prioridade,
// e devolve o alvo. Não guarda nada entre chamadas, então é fácil testar e não tem efeitos colaterais.
public static class TargetSelector
{
// Só considera alvos vivos, no Quadrant pedido e com a BORDA dentro do alcance (medido do centro do planeta).
// Devolve null se não houver ninguém válido.
// Estratégia: uma passada só pela lista, guardando o "melhor até agora" (menor score).
public static ITarget Select(IReadOnlyList<ITarget> targets, Quadrant quadrant, float range, TargetPriority priority)
{
ITarget best = null;
float bestScore = float.MaxValue; // qualquer score real é menor que isso

// for com índice (em vez de foreach) não aloca memória numa IReadOnlyList: importante num
// código que roda muitas vezes por segundo.
for (int i = 0; i < targets.Count; i++)
{
var target = targets[i];
if (!target.IsAlive) continue;

// sqrMagnitude = distância ao quadrado (mais barata, sem raiz). Serve para comparar distâncias.
float distanceSquared = (target.Position - WorldLayout.PlanetCenter).sqrMagnitude;
// Para o alcance precisamos da distância real, menos o raio (mede até a borda do alvo).
if (Mathf.Sqrt(distanceSquared) - target.Radius > range) continue;
if (Quadrants.FromPosition(target.Position) != quadrant) continue;

// Menor "score" vence: por isso Farthest e Strongest usam o valor negativo.
// "switch expression" (C# 8): escolhe um valor conforme o caso; "_" é o caso padrão.
float score = priority switch
{
TargetPriority.Closest => distanceSquared,
TargetPriority.Farthest => -distanceSquared,
TargetPriority.Weakest => target.CurrentHitpoints,
TargetPriority.Strongest => -target.CurrentHitpoints,
_ => distanceSquared,
};

if (score < bestScore)
{
bestScore = score;
best = target;
}
}

return best;
}
}
}

Passo 4 — Um pixel de cor: PixelSprite​

O rastro dos projéteis e as linhas dos eixos são desenhados com sprites de 1 pixel. Em vez de importar um PNG de 1×1, o jogo cria esse sprite na primeira vez que precisa.

// Caminho: Assets/_Project/Scripts/World/PixelSprite.cs
using UnityEngine;

namespace Armageddon.World
{
// POR QUE: rastros de projétil, linhas de eixo e outros detalhes são feitos de pixels soltos de cor.
// Importar um PNG de 1×1 só para isso é burocrático.
// ESTRATÉGIA: classe estática que cria, na primeira vez que alguém pede, um sprite branco de 1 pixel
// e o reaproveita para sempre ("inicialização preguiçosa"). A cor final vem do SpriteRenderer.color,
// que multiplica o branco por qualquer cor.
public static class PixelSprite
{
private static Sprite _white; // guardado depois da primeira criação

// Propriedade com "get" que cria o sprite só se ainda não existe.
public static Sprite White
{
get
{
if (_white != null) return _white;
// Textura 1×1 com filtro Point (sem borrar, como toda a pixel art do jogo).
var texture = new Texture2D(1, 1) { filterMode = FilterMode.Point };
texture.SetPixel(0, 0, Color.white);
texture.Apply(); // envia o pixel alterado para a placa de vídeo
// Sprite.Create(textura, área, pivô no centro, pixels por unidade = 16): 1 pixel = 1/16 u.
_white = Sprite.Create(texture, new Rect(0, 0, 1, 1), new Vector2(0.5f, 0.5f), WorldLayout.PixelsPerUnit);
return _white;
}
}
}
}

Passo 5 — O projétil: Projectile, ProjectilePool​

// Caminho: Assets/_Project/Scripts/Combat/Projectile.cs
using System;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: o dano dos Satellites precisa "viajar" até o alvo para o jogador ver o tiro; o GDD pede
// projétil teleguiado que nunca erra (Seção 4.1).
// ESTRATÉGIA: um MonoBehaviour que, a cada frame, anda em linha reta NO CHÃO na direção da posição ATUAL
// do alvo; quando chega perto, aplica o dano e se devolve ao pool. Não usa física (Rigidbody/Collider):
// uma conta de distância é mais barata e suficiente.
// Como todo objeto do mundo (Fase 2), a raiz fica no chão e a bolinha fica no filho View (IsoView).
// Nunca é destruído: quem o criou (ProjectilePool) passa uma função "release" para devolvê-lo.
// Se o alvo morrer antes, some (Seção 4.1).
// Desenha atrás de si um rastro curto de 3 pixels na direção do movimento NA TELA (Seção 6.2).
public sealed class Projectile : MonoBehaviour
{
// Em aberto na Seção 4.1: velocidade do projétil. Valor inicial: 10 u/s (no chão).
[SerializeField] private float _speed = 10f;
[SerializeField] private float _hitDistance = 0.2f; // distância que já conta como "acertou"
[SerializeField] private Color[] _trailColors = // um pixel de rastro por cor, do mais forte ao mais fraco
{
new Color32(255, 170, 40, 200),
new Color32(214, 110, 24, 140),
new Color32(150, 70, 20, 90),
};

private ITarget _target;
private float _damage;
private bool _isCritical;
// Action<Projectile> = uma variável que guarda uma FUNÇÃO que recebe um Projectile.
// Aqui guarda o "devolver ao pool", sem o projétil precisar conhecer o pool.
private Action<Projectile> _release;
private SpriteRenderer[] _trail;

// Cria os pixels do rastro uma vez só, como filhos do projétil (eles também são reaproveitados).
// A Sorting Layer vem do SpriteRenderer da bolinha, no filho View.
private void Awake()
{
var layer = GetComponentInChildren<SpriteRenderer>().sortingLayerName;
_trail = new SpriteRenderer[_trailColors.Length];
for (int i = 0; i < _trail.Length; i++)
{
var pixel = new GameObject("Trail").AddComponent<SpriteRenderer>();
pixel.transform.SetParent(transform, false);
pixel.sprite = PixelSprite.White;
pixel.color = _trailColors[i];
pixel.sortingLayerName = layer;
_trail[i] = pixel;
}
}

// "Dispara" um projétil que acabou de sair do pool: define origem (no chão), alvo, dano e como se devolver.
// Faz o papel de um construtor, já que objetos reaproveitados não passam por "new".
public void Launch(Vector2 from, ITarget target, float damage, bool isCritical, Action<Projectile> release)
{
transform.position = from;
_target = target;
_damage = damage;
_isCritical = isCritical;
_release = release;
// Esconde o rastro até o primeiro movimento, senão ele apareceria onde o projétil estava no disparo anterior.
foreach (var pixel in _trail) pixel.enabled = false;
}

// Persegue o alvo a cada frame (tudo no chão).
private void Update()
{
if (_target == null || !_target.IsAlive)
{
Release();
return;
}

Vector2 position = transform.position;
Vector2 toTarget = _target.Position - position;
float step = _speed * Time.deltaTime; // quanto anda neste frame

// Se o passo deste frame já alcança o alvo (ou está dentro da distância de acerto), acertou.
if (toTarget.magnitude <= Mathf.Max(step, _hitDistance))
{
_target.TakeDamage(_damage, _isCritical);
Release();
return;
}

// normalized = mesmo vetor com tamanho 1 (só a direção).
Vector2 direction = toTarget.normalized;
position += direction * step;
transform.position = position;
PlaceTrail(position, direction);
}

// Posiciona os pixels do rastro atrás da bolinha, NA TELA: projeta a cabeça e a direção do movimento
// (a projeção é linear, então projetar uma direção é projetar um vetor) e anda para trás em pixels.
// Não depende do IsoView: faz a mesma conta que ele, então o rastro sempre bate com a bolinha.
private void PlaceTrail(Vector2 groundHead, Vector2 groundDirection)
{
Vector2 head = IsoProjection.GroundToView(groundHead);
Vector2 direction = IsoProjection.GroundToView(groundDirection).normalized;
// Pixels atrás da bolinha (que tem 4 px), um por pixel de distância, presos à grade.
const float pixel = 1f / WorldLayout.PixelsPerUnit;
for (int i = 0; i < _trail.Length; i++)
{
_trail[i].transform.position = IsoProjection.Snap(head - direction * (2.5f + i) * pixel);
_trail[i].enabled = true;
}
}

// Devolve o projétil ao pool, garantindo que isso aconteça uma vez só.
private void Release()
{
_target = null;
var release = _release;
_release = null; // evita devolver duas vezes para o pool
release?.Invoke(this);
}
}
}
// Caminho: Assets/_Project/Scripts/Combat/ProjectilePool.cs
using UnityEngine;
using UnityEngine.Pool;

namespace Armageddon.Combat
{
// POR QUE: com 4 Satellites atirando várias vezes por segundo, criar (Instantiate) e destruir (Destroy)
// um projétil a cada tiro gera lixo de memória e engasgos do garbage collector no navegador.
// ESTRATÉGIA: object pooling com o ObjectPool<T> pronto da Unity (UnityEngine.Pool). O pool guarda
// projéteis desligados; "Get" liga um, "Release" desliga e guarda de volta. Nada é criado nem
// destruído durante a run. Os Satellites só chamam Fire; não sabem que existe um pool.
public sealed class ProjectilePool : MonoBehaviour
{
[SerializeField] private Projectile _prefab; // Prefab = "molde" de objeto salvo como asset, que pode ser copiado em cena
[SerializeField] private int _prewarm = 32; // quantos projéteis criar já no carregamento

private ObjectPool<Projectile> _pool;

// Monta o pool e o "aquece" (prewarm).
private void Awake()
{
// Cada parâmetro é uma função curta (lambda, "x => ...") que diz ao pool como fazer cada coisa.
_pool = new ObjectPool<Projectile>(
createFunc: () => Instantiate(_prefab, transform), // como criar um novo (filho deste objeto)
actionOnGet: p => p.gameObject.SetActive(true), // ao pegar: liga
actionOnRelease: p => p.gameObject.SetActive(false), // ao devolver: desliga
actionOnDestroy: p => Destroy(p.gameObject), // se o pool passar do maxSize, destrói o excedente
collectionCheck: false, // não confere devolução dupla (o Projectile já se protege); é mais rápido
defaultCapacity: _prewarm,
maxSize: 512);

// Cria os primeiros projéteis agora, no carregamento, e não no meio do primeiro combate.
var warm = new Projectile[_prewarm];
for (int i = 0; i < _prewarm; i++) warm[i] = _pool.Get();
for (int i = 0; i < _prewarm; i++) _pool.Release(warm[i]);
}

// O único método público: pega um projétil e o lança. _pool.Release é passado como a função
// "release" que o projétil chama quando acerta ou perde o alvo.
public void Fire(Vector2 from, ITarget target, float damage, bool isCritical)
{
_pool.Get().Launch(from, target, damage, isCritical, _pool.Release);
}
}
}

Passo 6 — O Satellite e a órbita: Satellite, SatelliteOrbit​

// Caminho: Assets/_Project/Scripts/Combat/Satellite.cs
using System;
using System.Collections.Generic;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: cada Satellite é uma arma independente: tem sua posição na órbita, sua Target Priority e
// seu próprio tempo de recarga (cooldown).
// ESTRATÉGIA: o Satellite NÃO tem Update próprio. O SatelliteOrbit decide onde ele fica (SetAngle) e
// manda ele "pensar" a cada frame (Tick), passando os Stats, os alvos e o pool. Assim a ordem de
// execução é controlada num lugar só e todos compartilham os mesmos Stats.
// Um Satellite: fica onde o SatelliteOrbit manda e atira no Quadrant onde está (Seção 4.1).
// Este objeto é a raiz, no chão; o desenho fica no filho View (IsoView, Fase 2), com orientação fixa:
// o sprite nunca gira (Seção 6.2).
public sealed class Satellite : MonoBehaviour
{
[SerializeField] private TargetPriority _priority = TargetPriority.Closest;

private float _cooldown; // segundos até poder atirar de novo (0 ou menos = pronto)

// Propriedade pública que lê/escreve o campo serializado: a UI (Fase 6) troca a prioridade por aqui.
public TargetPriority Priority
{
get => _priority;
set => _priority = value;
}

public float AngleDegrees { get; private set; }
public Quadrant CurrentQuadrant => Quadrants.FromAngle(AngleDegrees);

// Avisado a cada disparo (o QuadrantView usa para piscar o Quadrant).
public event Action<Satellite> Fired;

// Posiciona o Satellite na órbita a partir de um ângulo.
// Trigonometria básica: (cos θ, sin θ) é o ponto do círculo de raio 1 no ângulo θ; multiplicar
// pelo raio da órbita leva ao círculo certo.
public void SetAngle(float degrees)
{
AngleDegrees = Mathf.Repeat(degrees, 360f);
float radians = AngleDegrees * Mathf.Deg2Rad; // Cos/Sin trabalham em radianos
Vector2 p = WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)) * WorldLayout.OrbitRadius;
transform.position = new Vector3(WorldLayout.SnapToPixel(p.x), WorldLayout.SnapToPixel(p.y), 0f);
}

// Chamado pelo SatelliteOrbit a cada frame. Com o cooldown zerado e ninguém válido, segura o tiro.
public void Tick(float deltaTime, SatelliteStats stats, IReadOnlyList<ITarget> targets, ProjectilePool projectiles)
{
if (_cooldown > 0f)
{
_cooldown -= deltaTime;
if (_cooldown > 0f) return;
}

var target = TargetSelector.Select(targets, CurrentQuadrant, stats.attackRange, _priority);
if (target == null)
{
_cooldown = 0f; // pronto para atirar assim que alguém entrar no Quadrant
return;
}

float distance = Vector2.Distance(target.Position, WorldLayout.PlanetCenter);
float damage = stats.RollDamage(distance, out bool isCritical);
projectiles.Fire(transform.position, target, damage, isCritical);

// "+=" em vez de "=": se o cooldown passou um pouco do zero, a sobra é descontada do próximo,
// e a cadência média fica exata mesmo com frames irregulares.
_cooldown += 1f / Mathf.Max(0.01f, stats.attackSpeed);
Fired?.Invoke(this);
}
}
}
// Caminho: Assets/_Project/Scripts/Combat/SatelliteOrbit.cs
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: todos os Satellites compartilham o mesmo círculo e precisam ficar igualmente espaçados,
// mesmo quando um novo é adicionado. Se cada um girasse sozinho, eles poderiam se desalinhar.
// ESTRATÉGIA: um "dono da órbita": guarda UM ângulo global, cria/remove os Satellites, e no Update
// posiciona todos (ângulo + i × espaçamento) e chama o Tick de cada um na ordem. Também guarda os
// SatelliteStats compartilhados e repassa o evento de disparo para quem estiver ouvindo.
// A órbita compartilhada: todos os Satellites no mesmo círculo do chão (uma elipse na tela), igualmente
// espaçados, girando juntos no sentido anti-horário na velocidade do Stat Orbit Speed (Seção 4.1).
public sealed class SatelliteOrbit : MonoBehaviour
{
public const int MaxSatellites = 4;

[SerializeField] private Satellite _satellitePrefab;
[SerializeField] private ProjectilePool _projectiles;
[SerializeField, Range(1, MaxSatellites)] private int _startingCount = 1;
[SerializeField] private SatelliteStats _stats = new SatelliteStats(); // editável no Inspector até a Fase 6

private readonly List<Satellite> _satellites = new List<Satellite>();
private float _angle = 45f; // o primeiro Satellite começa na ponta de cima do losango (Quadrant Top)

public IReadOnlyList<Satellite> Satellites => _satellites;
public SatelliteStats Stats => _stats;

// Repassa o Fired de qualquer Satellite: quem ouve não precisa se inscrever em cada um.
public event Action<Satellite> SatelliteFired;
public event Action CountChanged; // a UI (SatellitePanel) redesenha quando muda o número de Satellites

// Cria os Satellites iniciais.
private void Start()
{
SetCount(_startingCount);
}

// Troca os Stats de todos os Satellites de uma vez (chamado pela economia da run na Fase 6).
public void SetStats(SatelliteStats stats)
{
_stats = stats;
}

// Compra de Satellites na run (SatelliteShop, Fase 6). Os novos entram já espaçados; ninguém "pula" de posição.
// Adiciona ou remove Satellites até chegar em "count".
public void SetCount(int count)
{
count = Mathf.Clamp(count, 1, MaxSatellites);

while (_satellites.Count < count)
{
// Instantiate(prefab, pai): cria uma cópia do prefab como filho deste objeto.
var satellite = Instantiate(_satellitePrefab, transform);
satellite.Fired += HandleSatelliteFired;
_satellites.Add(satellite);
}

while (_satellites.Count > count)
{
var last = _satellites[_satellites.Count - 1];
last.Fired -= HandleSatelliteFired; // desinscreve antes de destruir
_satellites.RemoveAt(_satellites.Count - 1);
Destroy(last.gameObject);
}

PlaceSatellites();
CountChanged?.Invoke();
}

// Um único Update move a órbita e faz todos os Satellites pensarem, na ordem.
private void Update()
{
float deltaTime = Time.deltaTime;
if (deltaTime <= 0f) return; // pausado: nem gira nem atira

_angle = Mathf.Repeat(_angle + _stats.orbitSpeed * deltaTime, 360f);
PlaceSatellites();

var targets = TargetRegistry.Alive;
for (int i = 0; i < _satellites.Count; i++)
{
_satellites[i].Tick(deltaTime, _stats, targets, _projectiles);
}
}

// Distribui os Satellites igualmente: com 3, ficam a 120° um do outro.
private void PlaceSatellites()
{
if (_satellites.Count == 0) return;
float spacing = 360f / _satellites.Count;
for (int i = 0; i < _satellites.Count; i++)
{
_satellites[i].SetAngle(_angle + i * spacing);
}
}

// Recebe o Fired de um Satellite e o repassa como SatelliteFired.
private void HandleSatelliteFired(Satellite satellite)
{
SatelliteFired?.Invoke(satellite);
}
}
}

Passo 7 — Mostrar os Quadrants: QuadrantView​

Como definido na Seção 6.2: as linhas dos eixos ficam sempre visíveis e discretas, e o Quadrant onde há um Satellite ganha um preenchimento translúcido quente do tamanho do Attack Range, que pisca mais forte a cada disparo. Tudo isso fica deitado no grid: o QuadrantView desenha em coordenadas do chão, como se o jogo fosse top-down, e mora dentro do IsoFloor/Plane, que faz a projeção (Fase 2, Passo 10).

// Caminho: Assets/_Project/Scripts/Combat/QuadrantView.cs
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: a regra "o Satellite só atira no seu Quadrant, até o Attack Range" é invisível. O jogador
// precisa VER a área coberta para entender o combate e decidir os Upgrades.
// ESTRATÉGIA: um componente só visual. Cria por código (1) as duas linhas dos eixos e (2) quatro
// preenchimentos de quarto de círculo, um por Quadrant. A cada frame, liga os preenchimentos onde há
// Satellite e faz piscar o Quadrant que acabou de atirar (ouvindo o evento SatelliteFired).
// Não mexe no combate: só lê o SatelliteOrbit.
// Precisa ser filho do IsoFloor/Plane: os desenhos usam posições LOCAIS do chão, e a hierarquia os deita no grid.
// Desenha os eixos dos Quadrants e o preenchimento dos Quadrants cobertos (Seção 6.2).
public sealed class QuadrantView : MonoBehaviour
{
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private Color _fillColor = new Color(1f, 0.67f, 0.24f, 0.13f);
[SerializeField] private float _flashAlpha = 0.3f; // alpha no auge da piscada
[SerializeField] private float _flashDecayPerSecond = 4f; // quão rápido a piscada apaga
[SerializeField] private Color _axisColor = new Color(0.9f, 0.86f, 1f, 0.16f);
[SerializeField] private string _sortingLayer = "Floor";

// Arrays de 4 posições, indexados pelo número do Quadrant (0–3).
private readonly SpriteRenderer[] _fills = new SpriteRenderer[4];
private readonly float[] _flash = new float[4]; // 1 = acabou de piscar, 0 = normal
private readonly bool[] _covered = new bool[4]; // tem Satellite neste Quadrant agora?
private Texture2D _fillTexture;
private float _builtRange = -1f; // alcance para o qual a textura foi desenhada (-1 = nenhum ainda)
private bool _fillVisible = true;

// Start porque depende do _orbit (outro objeto) já ter feito seu Awake.
private void Start()
{
BuildAxes();
_orbit.SatelliteFired += HandleSatelliteFired;
}

// Desinscreve do evento e destrói a textura criada por código. Texturas criadas com "new" não são
// liberadas sozinhas quando o objeto some: sem o Destroy, ficariam ocupando memória.
private void OnDestroy()
{
if (_orbit != null) _orbit.SatelliteFired -= HandleSatelliteFired;
if (_fillTexture != null) Destroy(_fillTexture);
}

// "Mostrar Quadrants" das Settings (Fase 11): esconde o preenchimento; as linhas continuam.
public void SetFillVisible(bool visible)
{
_fillVisible = visible;
}

// LateUpdate (ver CameraRig): roda depois do Update do SatelliteOrbit, então já vê os Satellites
// nas posições deste frame.
private void LateUpdate()
{
// Só redesenha a textura quando o Attack Range muda (Upgrade), não todo frame.
// Mathf.Approximately compara floats com tolerância (floats raramente são exatamente iguais).
float range = _orbit.Stats.attackRange;
if (!Mathf.Approximately(range, _builtRange)) BuildFills(range);

for (int q = 0; q < 4; q++) _covered[q] = false;
foreach (var satellite in _orbit.Satellites) _covered[(int)satellite.CurrentQuadrant] = true;

for (int q = 0; q < 4; q++)
{
_flash[q] = Mathf.Max(0f, _flash[q] - _flashDecayPerSecond * Time.deltaTime);
var color = _fillColor;
color.a = Mathf.Lerp(_fillColor.a, _flashAlpha, _flash[q]);
_fills[q].color = color;
_fills[q].enabled = _fillVisible && _covered[q];
}
}

// Um disparo acende a piscada do Quadrant de quem atirou.
private void HandleSatelliteFired(Satellite satellite)
{
_flash[(int)satellite.CurrentQuadrant] = 1f;
}

// Um quarto de círculo de raio = Attack Range, pixel a pixel (no chão). Girado de 90 em 90 graus para
// os 4 Quadrants. O IsoFloor/Plane achata o resultado: na tela ele vira uma ponta do losango.
// Estratégia: uma textura quadrada de "size × size" pixels; cada pixel é branco se está dentro do
// círculo (x² + y² ≤ r²) e transparente se está fora. A cor final vem do SpriteRenderer.color.
private void BuildFills(float range)
{
_builtRange = range;
int size = Mathf.Max(1, Mathf.CeilToInt(range * WorldLayout.PixelsPerUnit));

if (_fillTexture != null) Destroy(_fillTexture); // libera a textura do alcance antigo
_fillTexture = new Texture2D(size, size) { filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp };
var pixels = new Color32[size * size]; // preencher um array e enviar de uma vez é muito mais rápido que SetPixel um a um
float radiusSquared = size * size;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
float dx = x + 0.5f, dy = y + 0.5f; // +0,5 = centro do pixel
pixels[y * size + x] = dx * dx + dy * dy <= radiusSquared ? new Color32(255, 255, 255, 255) : new Color32(0, 0, 0, 0);
}
}
_fillTexture.SetPixels32(pixels);
_fillTexture.Apply();
// Pivô Vector2.zero = canto de baixo à esquerda: esse canto fica no centro do planeta (no chão).
var sprite = Sprite.Create(_fillTexture, new Rect(0, 0, size, size), Vector2.zero, WorldLayout.PixelsPerUnit);

for (int q = 0; q < 4; q++)
{
// Cria os 4 SpriteRenderers só na primeira vez; depois só troca o sprite.
if (_fills[q] == null)
{
_fills[q] = new GameObject($"Fill {(Quadrant)q}").AddComponent<SpriteRenderer>();
_fills[q].transform.SetParent(transform, false);
// Quaternion.Euler(0, 0, ângulo) = rotação em volta do eixo Z (o "giro" em 2D).
_fills[q].transform.localRotation = Quaternion.Euler(0f, 0f, q * 90f);
_fills[q].sortingLayerName = _sortingLayer;
}
_fills[q].transform.localPosition = WorldLayout.PlanetCenter; // local: o pai (Plane) faz a projeção
_fills[q].sprite = sprite;
}
}

// Duas linhas pontilhadas (1 px aceso, 2 apagados) cruzando o chão; na tela, as diagonais centrais do grid.
// Uma textura de 1 pixel de altura, usada duas vezes: deitada (eixo X) e girada 90° (eixo Y).
private void BuildAxes()
{
int length = Mathf.CeilToInt(WorldLayout.SpawnRadius * 2f * WorldLayout.PixelsPerUnit);
var texture = new Texture2D(length, 1) { filterMode = FilterMode.Point, wrapMode = TextureWrapMode.Clamp };
for (int x = 0; x < length; x++) texture.SetPixel(x, 0, x % 3 == 0 ? Color.white : Color.clear);
texture.Apply();
var sprite = Sprite.Create(texture, new Rect(0, 0, length, 1), new Vector2(0.5f, 0.5f), WorldLayout.PixelsPerUnit);

for (int i = 0; i < 2; i++)
{
var axis = new GameObject(i == 0 ? "Axis X" : "Axis Y").AddComponent<SpriteRenderer>();
axis.transform.SetParent(transform, false);
axis.transform.localPosition = WorldLayout.PlanetCenter;
axis.transform.localRotation = Quaternion.Euler(0f, 0f, i * 90f);
axis.sprite = sprite;
axis.color = _axisColor;
axis.sortingLayerName = _sortingLayer;
axis.sortingOrder = 1; // por cima dos preenchimentos
}
}
}
}

Passo 8 — Ferramentas de teste de combate: TargetDummy, CombatDevTools​

// Caminho: Assets/_Project/Scripts/Combat/TargetDummy.cs
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: os inimigos só chegam na Fase 4, mas já queremos ver os Satellites atirando agora.
// ESTRATÉGIA: o alvo mais simples possível que cumpre o contrato ITarget: fica parado, tem HP e se
// destrói ao morrer. Prova que o combate funciona com QUALQUER ITarget (ver ITarget).
// Alvo parado, só para testes até os inimigos existirem (Fase 4). Some do build final.
// ", ITarget" depois de MonoBehaviour = "esta classe cumpre o contrato ITarget".
public sealed class TargetDummy : MonoBehaviour, ITarget
{
private float _hitpoints;

public Vector2 Position => transform.position;
public float Radius => 0.5f;
public float CurrentHitpoints => _hitpoints;
public bool IsAlive => _hitpoints > 0f;

// Fábrica estática: cria o objeto do zero por código (sem prefab) e devolve o dummy pronto.
// Mesma estrutura dos objetos do jogo (Fase 2): a raiz no chão, o desenho num filho View com IsoView.
public static TargetDummy Spawn(Vector2 groundPosition, Sprite sprite, float hitpoints)
{
var go = new GameObject("TargetDummy");
go.transform.position = groundPosition;
var view = new GameObject("View");
view.transform.SetParent(go.transform, false);
var renderer = view.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "World";
view.AddComponent<IsoView>().SetShadowRadius(0.5f);
var dummy = go.AddComponent<TargetDummy>();
dummy._hitpoints = hitpoints;
return dummy;
}

// Entra na lista de alvos ao ser ligado e sai ao ser desligado/destruído (ver TargetRegistry).
private void OnEnable() => TargetRegistry.Register(this);
private void OnDisable() => TargetRegistry.Unregister(this);

// Cumpre ITarget.TakeDamage: desconta o HP, mostra no Console e se destrói ao zerar.
public void TakeDamage(float amount, bool isCritical)
{
if (!IsAlive) return;
_hitpoints -= amount;
Debug.Log($"[Dummy] -{amount:0.#}{(isCritical ? " CRÍTICO" : "")} → {Mathf.Max(0f, _hitpoints):0.#} HP");
if (_hitpoints <= 0f) Destroy(gameObject);
}
}
}
#endif
// Caminho: Assets/_Project/Scripts/Combat/CombatDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.World;
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Combat
{
// POR QUE: testar o combate exige criar alvos, mudar o número de Satellites e trocar a Target Priority
// sem ter UI ainda.
// ESTRATÉGIA: um componente colocado na cena Gameplay (diferente do DevShortcuts, que vive no [Services])
// porque precisa de referências da cena (_orbit) arrastadas no Inspector.
// Teclas de teste do combate: T = alvo de teste no mouse · 1–4 = número de Satellites · Q = troca a Target Priority.
// A CLASSE existe em todo build (ela fica numa cena); os campos e o Update somem do build final.
// Se a classe inteira sumisse, a cena teria um "Missing Script" no build final.
public sealed class CombatDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private Sprite _dummySprite;
[SerializeField] private float _dummyHitpoints = 30f;

// Lê as teclas de teste a cada frame.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.tKey.wasPressedThisFrame && Mouse.current != null && Camera.main != null)
{
// O mouse está na vista; o alvo nasce no ponto do chão embaixo dele (ver DevShortcuts, F4).
Vector2 view = Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue());
TargetDummy.Spawn(IsoProjection.ViewToGround(view), _dummySprite, _dummyHitpoints);
}

if (keyboard.digit1Key.wasPressedThisFrame) _orbit.SetCount(1);
if (keyboard.digit2Key.wasPressedThisFrame) _orbit.SetCount(2);
if (keyboard.digit3Key.wasPressedThisFrame) _orbit.SetCount(3);
if (keyboard.digit4Key.wasPressedThisFrame) _orbit.SetCount(4);

if (keyboard.qKey.wasPressedThisFrame)
{
// Avança para a próxima prioridade do enum; "% 4" volta para a primeira depois da última.
foreach (var satellite in _orbit.Satellites)
{
satellite.Priority = (TargetPriority)(((int)satellite.Priority + 1) % 4);
}
Debug.Log($"[Dev] Target Priority: {_orbit.Satellites[0].Priority}");
}
}
#endif
}
}

Passo 9 — Prefabs e cena​

  1. Prefab do projétil: crie um objeto vazio Projectile com o componente Projectile (valores padrão). Dentro dele, um filho vazio View com:

    • Sprite Renderer: célula 0 de Art/Projectiles/SPR_Projectile_Satellite; Sorting Layer World.
    • Sprite Animator: as 2 células; Fps 8; Loop marcado (Seção 6.4).
    • Iso View: Shadow Radius 0 (projéteis não têm sombra, Seção 6.2).

    Arraste para Assets/_Project/Prefabs/Gameplay/ e apague da cena.

  2. Prefab do Satellite: objeto vazio Satellite com o componente Satellite (Priority Closest). Dentro dele, um filho vazio View com:

    • Sprite Renderer: célula 0 de Art/Satellites/SPR_Satellite; Sorting Layer World.
    • Sprite Animator: as 2 células; Fps 2; Loop marcado.
    • Iso View: Shadow Radius 0.25.

    Arraste para Prefabs/Gameplay/ e apague da cena.

  3. Na cena Gameplay:

    • Em [Systems], crie ProjectilePool com o componente ProjectilePool, e o prefab Projectile em Prefab.
    • Em [World], crie SatelliteOrbit na posição (0, 0, 0) com o componente SatelliteOrbit: Satellite Prefab = Satellite; Projectiles = o ProjectilePool da cena; Starting Count 1. Os Stats já vêm com os valores base.
    • Dentro de [World]/IsoFloor/Plane (Fase 2), crie QuadrantView com Position (0, 0, 0), Rotation (0, 0, 0) e Scale (1, 1, 1) locais, com o componente QuadrantView e o SatelliteOrbit em Orbit.
    • Em [Systems], crie CombatDevTools com o componente CombatDevTools: Orbit = o SatelliteOrbit; Dummy Sprite = a célula 0 de Art/Enemies/SPR_Enemy_Grunt.

Passo 10 — Commit​

git add . e git commit -m "Phase 3: satellites, orbit, quadrants, target priority, homing projectiles".

✅ Checkpoint:

  • Play na Gameplay: um Satellite começa na ponta de cima do losango e dá uma volta em 8 s no sentido anti-horário (Orbit Speed 45°/s), numa elipse em volta do planeta. Na metade de cima da elipse ele passa por trás do planeta; na de baixo, pela frente. A sombra dele corre pelo grid. O Quadrant onde ele está fica levemente iluminado no grid (uma ponta do losango), e o destaque passa de Quadrant em Quadrant junto com ele.
  • As duas linhas pontilhadas dos eixos correm na diagonal sobre o grid e ficam sempre visíveis.
  • T com o mouse dentro da elipse laranja dos Gizmos cria um alvo de teste, com sombra. O Satellite só atira nele enquanto estiver no mesmo Quadrant. Um alvo cuja borda fica fora da elipse (Attack Range 4 u; o alvo de teste tem raio 0,5 u) nunca é atingido.
  • Cada disparo é uma bolinha com um rastro curto que persegue o alvo e faz o Quadrant piscar. O Console mostra -10 → 20 HP, e na terceira bala o alvo some.
  • Com a cadência base (1 disparo/s) e 2 s em cada Quadrant, um alvo de 30 HP colocado sozinho num Quadrant morre depois de umas duas passagens do Satellite.
  • 2, 3 e 4 trocam o número de Satellites, que se reorganizam igualmente espaçados. Com 4, os quatro Quadrants ficam acesos o tempo todo.
  • Com dois alvos no mesmo Quadrant (um perto e um longe do planeta), Q alterna a Target Priority: em Closest o de perto é atingido primeiro; em Farthest, o de longe.
  • No Inspector do SatelliteOrbit, Critical Chance 0,5 faz metade dos disparos aparecer como CRÍTICO, com 15 de dano (10 × 1,5).
  • Esc pausa tudo: a órbita, os projéteis no ar e o piscar do Quadrant.

Problemas comuns:

  • O Satellite gira mas nunca atira: o alvo está fora do Attack Range (confira a elipse laranja dos Gizmos), ou o SatelliteOrbit está sem o Projectiles preenchido (o Console mostra um NullReferenceException).
  • O Satellite atira num alvo de outro Quadrant: confira se o objeto SatelliteOrbit e o planeta estão na posição (0, 0, 0). Os Quadrants são medidos a partir de WorldLayout.PlanetCenter, que é a origem do chão. Confira também se o alvo nasceu do lado certo: o T converte o mouse para o chão (ViewToGround); sem isso, o alvo nasceria num ponto do chão que não é o que você vê.
  • O preenchimento aparece de pé (um quarto de círculo redondo) em vez de deitado no grid: o QuadrantView não está dentro do IsoFloor/Plane, ou o IsoFloor está sem o Plane preenchido.
  • O preenchimento aparece por cima do planeta ou dos inimigos: a Sorting Layer Floor precisa estar entre Grid e World (Fase 0, Passo 7).
  • O Satellite fica sempre na frente do planeta, mesmo atrás dele: o Transparency Sort Axis do Renderer 2D não está em (0, 1, 0) (Fase 2, Passo 13), ou o View do Satellite não está na mesma Sorting Layer (World) do planeta.
  • O preenchimento aparece borrado: a Pixel Perfect Camera precisa estar com Grid Snapping = Upscale Render Texture (Fase 2).
  • O projétil some antes de chegar: o alvo morreu no caminho (outro projétil chegou antes). É o comportamento definido na Seção 4.1.
  • Missing (Mono Script) no CombatDevTools de um build: alguém envolveu a classe inteira em #if. Só os campos e o Update ficam dentro do #if, como no código acima.

Próxima fase: Fase 4 — Inimigos. Ela cria o Enemy (que implementa ITarget), os 4 inimigos comuns com os seus movimentos, o pooling e a explosão ao morrer.


Fase 4 — Inimigos​

Objetivo desta fase: os 4 inimigos comuns (Grunt, Scout, Swarmer e Brute) existem como dados, nascem, avançam até o planeta com o seu movimento, causam dano ao tocar e explodem ao morrer para os Satellites. Todos são reaproveitados por pooling. As Waves (quem nasce, quando e onde) entram na Fase 5; aqui os inimigos são criados por teclas de teste.

Conceitos novos:

  • ScriptableObject (EnemyDefinition): um arquivo de dados na pasta do projeto, editado no Inspector, que não pertence a nenhuma cena. Cada inimigo é um asset (Enemy_Grunt, Enemy_Scout…) com os valores da Seção 4.4. Mudar o HP do Grunt é mudar um número num asset, sem tocar em código.
  • Um prefab para todos os inimigos comuns: o Enemy não sabe se é um Grunt ou um Brute: ele recebe uma EnemyDefinition ao nascer e passa a se comportar como ela. É isso que permite um pool só para os 4 tipos.
  • Estratégia de movimento (IEnemyMovement): o jeito de andar é um objeto separado que o inimigo usa. Linha reta e zigue-zague são duas implementações da mesma interface; um movimento novo (Seção 9) é só mais uma classe.
  • Kamikaze: o inimigo não atira. Ele anda até o planeta e, ao tocar (distância ao centro < raio do planeta, Seção 4.4), causa dano e some sem dar Shards.
  • Eventos de morte: o EnemyPool avisa quando um inimigo foi destruído por um Satellite (EnemyKilled) ou quando bateu no planeta (EnemyReachedPlanet). Quem se importa escuta: os Shards (Fase 6), as estatísticas da run (Fase 8) e as conquistas (Fase 9). O inimigo não precisa conhecer nenhum desses sistemas.

Passo 1 — Movimentos: IEnemyMovement​

// Caminho: Assets/_Project/Scripts/Enemies/EnemyMovement.cs
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: tipos diferentes de inimigo andam de jeitos diferentes (reto, zigue-zague, e mais no
// futuro, Seção 9). Colocar um "if tipo == Scout" dentro do Enemy cresceria sem controle.
// ESTRATÉGIA: padrão "Strategy": o jeito de andar é um objeto separado que cumpre esta interface.
// Todo inimigo avança em linha reta para o centro do planeta. O movimento só decide o desvio LATERAL
// (perpendicular à direção do planeta) em função do tempo de vida do inimigo.
// Por ser uma função do tempo (e não "mova-se um pouco"), o movimento não precisa guardar estado.
public interface IEnemyMovement
{
float LateralOffset(float time); // desvio lateral, em u, depois de "time" segundos de vida
}

// POR QUE: o Inspector não sabe mostrar uma interface; o designer escolhe o movimento por este enum,
// e a EnemyDefinition converte o enum na classe certa.
public enum MovementKind { Straight, ZigZag }

// POR QUE: Grunt, Swarmer e Brute andam em linha reta (Seção 4.4).
// ESTRATÉGIA: desvio sempre 0. Não guarda estado: uma instância serve para todos ("Instance" é um
// singleton simples: um único objeto compartilhado, criado uma vez).
public sealed class StraightMovement : IEnemyMovement
{
public static readonly StraightMovement Instance = new StraightMovement();

public float LateralOffset(float time) => 0f;
}

// POR QUE: o Scout faz um zigue-zague leve (amplitude 0,5 u, 1,5 Hz, Seção 4.4), mais difícil de acertar.
// ESTRATÉGIA: uma onda senoidal: amplitude × sen(2π × frequência × tempo). Os parâmetros são fixados
// no construtor e nunca mudam, então a instância também pode ser compartilhada.
public sealed class ZigZagMovement : IEnemyMovement
{
private readonly float _amplitude; // quanto se afasta para cada lado, em u
private readonly float _frequency; // quantas idas e voltas por segundo (Hz)

public ZigZagMovement(float amplitude, float frequency)
{
_amplitude = amplitude;
_frequency = frequency;
}

public float LateralOffset(float time) => _amplitude * Mathf.Sin(2f * Mathf.PI * _frequency * time);
}
}

Passo 2 — Os dados de um inimigo: EnemyDefinition​

// Caminho: Assets/_Project/Scripts/Enemies/EnemyDefinition.cs
using System;
using System.Collections.Generic;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: Grunt, Scout, Swarmer e Brute diferem só em números e sprites. Criar uma classe (ou um
// prefab) por inimigo duplicaria código; deixar os números no código obrigaria a recompilar para
// balancear.
// ESTRATÉGIA: ScriptableObject = um asset de dados que vive na pasta do projeto (não numa cena).
// Cada inimigo é um arquivo (Enemy_Grunt.asset...) editado no Inspector. Um único prefab de Enemy
// recebe a definição ao nascer. Balancear vira editar asset, sem mexer em código.
// [CreateAssetMenu] adiciona a opção "Create > Armageddon > Enemy Definition" no botão direito da janela Project.
// Um inimigo comum (Seção 4.4). Os valores são os da Wave 1; a escalada por Wave (Seção 4.3) é
// aplicada no spawn, pelos multiplicadores que a Fase 5 calcula.
[CreateAssetMenu(menuName = "Armageddon/Enemy Definition", fileName = "Enemy_")]
public sealed class EnemyDefinition : ScriptableObject
{
// [Header] só desenha um título no Inspector para agrupar os campos.
[Header("Valores na Wave 1 (Seção 4.4)")]
[SerializeField] private float _hitpoints = 10f;
[SerializeField] private float _damage = 5f;
[SerializeField] private float _speed = 0.8f; // u/s
[SerializeField] private float _shards = 1f; // 0,5 = "1 a cada 2" (Swarmer)
[SerializeField] private float _radius = 0.5f; // metade do sprite, em u: o alcance mede até a borda (Seção 4.1)

[Header("Movimento")]
[SerializeField] private MovementKind _movement = MovementKind.Straight;
[SerializeField] private float _zigZagAmplitude = 0.5f;
[SerializeField] private float _zigZagFrequency = 1.5f;

[Header("Spawn (usados pelas Waves, Fase 5)")]
[SerializeField] private int _unlockWave = 1; // a partir de qual Wave pode aparecer
[SerializeField] private float _spawnWeight = 50f; // peso no sorteio: maior = aparece mais
[SerializeField] private int _clusterSize = 1; // Swarmer: 8
[SerializeField] private float _clusterSpread = 0.6f; // raio do cacho, em u

[Header("Visual (Seção 6.4)")]
[SerializeField] private List<SpriteVariant> _variants = new List<SpriteVariant>();
[SerializeField] private float _fps = 6f;
[SerializeField] private ExplosionKind _explosion = ExplosionKind.Small;

// [NonSerialized]: cache só em memória, nunca salvo no asset. Criado na primeira vez que alguém pede.
// Atenção: por ficar em cache, mudar a amplitude/frequência no Inspector DURANTE o Play não tem efeito
// até o próximo Play.
[NonSerialized] private IEnemyMovement _movementInstance;

// Propriedades só de leitura: o resto do jogo lê os valores, mas só o Inspector os altera.
public float Hitpoints => _hitpoints;
public float Damage => _damage;
public float Speed => _speed;
public float Shards => _shards;
public float Radius => _radius;
public int UnlockWave => _unlockWave;
public float SpawnWeight => _spawnWeight;
public int ClusterSize => _clusterSize;
public float ClusterSpread => _clusterSpread;
public IReadOnlyList<SpriteVariant> Variants => _variants;
public float Fps => _fps;
public ExplosionKind Explosion => _explosion;

// Converte o enum do Inspector no objeto de movimento, uma vez só.
// Os movimentos não guardam estado, então todos os inimigos do mesmo tipo usam a mesma instância.
public IEnemyMovement Movement
{
get
{
if (_movementInstance == null)
{
_movementInstance = _movement == MovementKind.ZigZag
? new ZigZagMovement(_zigZagAmplitude, _zigZagFrequency)
: StraightMovement.Instance;
}
return _movementInstance;
}
}
}

// POR QUE: um inimigo pode ter várias versões visuais (cores/desenhos), sorteadas no spawn.
// Uma variação visual: os frames de uma linha da spritesheet (Grunt), ou uma célula só (Brute).
// ESTRATÉGIA: o Unity não serializa Sprite[][], por isso cada variação é um objeto com a sua lista.
[Serializable]
public sealed class SpriteVariant
{
public Sprite[] frames;
}
}

Passo 3 — As explosões: ExplosionPool​

// Caminho: Assets/_Project/Scripts/World/ExplosionPool.cs
using System;
using UnityEngine;
using UnityEngine.Pool;

namespace Armageddon.World
{
// POR QUE: cada inimigo tem um tamanho de explosão; o enum dá nome a cada um.
public enum ExplosionKind { Small, Big, Mothership }

// POR QUE: dezenas de inimigos morrem por segundo nas Waves altas; criar e destruir um objeto de
// explosão por morte seria o mesmo problema dos projéteis (ver ProjectilePool).
// ESTRATÉGIA: pool de objetos com SpriteAnimator em modo "uma vez". Cada explosão se devolve ao pool
// sozinha quando o animator avisa Finished. Quem chama só diz "tipo + posição".
// Toca uma explosão (animação "uma vez", Seção 6.4) e devolve o objeto ao pool quando ela termina.
// A explosão é só desenho e não se move: ela recebe a posição do CHÃO e já nasce na posição projetada,
// sem precisar de IsoView.
public sealed class ExplosionPool : MonoBehaviour
{
// Classe aninhada (dentro da outra) e privada: só existe para organizar o Inspector deste componente.
[Serializable]
private sealed class ExplosionFrames
{
public ExplosionKind kind;
public Sprite[] frames;
public float fps = 15f;
}

[SerializeField] private ExplosionFrames[] _explosions;
[SerializeField] private int _prewarm = 16;
[SerializeField] private string _sortingLayer = "VFX";

private ObjectPool<SpriteAnimator> _pool;

// Monta e aquece o pool (mesma estratégia do ProjectilePool).
private void Awake()
{
_pool = new ObjectPool<SpriteAnimator>(
createFunc: Create,
actionOnGet: a => a.gameObject.SetActive(true),
actionOnRelease: a => a.gameObject.SetActive(false),
actionOnDestroy: a => Destroy(a.gameObject),
collectionCheck: false,
defaultCapacity: _prewarm,
maxSize: 256);

var warm = new SpriteAnimator[_prewarm];
for (int i = 0; i < _prewarm; i++) warm[i] = _pool.Get();
for (int i = 0; i < _prewarm; i++) _pool.Release(warm[i]);
}

// Chamado pelo EnemyPool (e pela Mothership): toca a explosão do tipo pedido na posição do chão.
public void Play(ExplosionKind kind, Vector2 position)
{
var explosion = Find(kind);
if (explosion == null) return; // tipo sem frames configurados no Inspector: ignora sem erro

var animator = _pool.Get();
animator.transform.position = IsoProjection.Snap(IsoProjection.GroundToView(position));
animator.SetFrames(explosion.frames, explosion.fps, false); // false = toca uma vez
}

// Como o pool cria uma explosão nova: objeto vazio + SpriteRenderer + SpriteAnimator.
// A inscrição no Finished é feita UMA vez, na criação: toda vez que a animação terminar,
// o objeto volta ao pool sozinho.
private SpriteAnimator Create()
{
var go = new GameObject("Explosion");
go.transform.SetParent(transform, false);
go.AddComponent<SpriteRenderer>().sortingLayerName = _sortingLayer;
var animator = go.AddComponent<SpriteAnimator>();
animator.Finished += () => _pool.Release(animator);
return animator;
}

// Procura a configuração do tipo pedido. Lista pequena (3 itens): um laço simples basta.
private ExplosionFrames Find(ExplosionKind kind)
{
foreach (var explosion in _explosions)
{
if (explosion.kind == kind) return explosion;
}
return null;
}
}
}

Passo 4 — O inimigo em cena: Enemy​

// Caminho: Assets/_Project/Scripts/Enemies/Enemy.cs
using Armageddon.Combat;
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: o jogo precisa distinguir COMO o inimigo saiu, porque cada saída tem consequência diferente
// (Shards, dano, nada).
// Como um inimigo saiu de cena.
public enum EnemyExit
{
Killed, // destruído por um Satellite: vale Shards
ReachedPlanet, // kamikaze: bateu no planeta e causou dano
}

// POR QUE: é o inimigo "vivo" na tela: anda até o planeta, recebe tiros e morre.
// ESTRATÉGIA: um único prefab serve para todos os tipos: os números vêm da EnemyDefinition
// (ScriptableObject) e o jeito de andar vem do IEnemyMovement. Implementa ITarget para os Satellites
// poderem mirar nele. Vive num pool (EnemyPool): Spawn faz o papel de "construtor" e Die devolve ao pool.
// O Enemy não conhece Shards, estatísticas nem conquistas: só avisa o pool, que dispara os eventos.
// Um inimigo comum vivo. Recebe a EnemyDefinition ao nascer (um prefab serve para todos os tipos).
// Este objeto é a raiz, no chão; o desenho (SpriteRenderer + SpriteAnimator + IsoView) fica no filho View.
public sealed class Enemy : MonoBehaviour, ITarget
{
private EnemyDefinition _definition;
private IEnemyMovement _movement;
private PlanetHealth _planet; // a quem causar dano ao chegar
private EnemyPool _pool; // a quem avisar ao sair de cena
private SpriteAnimator _animator;
private IsoView _view;
private Vector2 _radialPosition; // a posição "na linha reta" até o planeta, sem o desvio lateral
private float _hitpoints;
private float _damage;
private float _age; // segundos de vida (alimenta o zigue-zague)
private bool _alive;

public EnemyDefinition Definition => _definition;
public Vector2 Position { get; private set; } // posição real (com o desvio lateral): é para onde os projéteis vão
public float Radius => _definition.Radius;
public float CurrentHitpoints => _hitpoints;
public bool IsAlive => _alive;

// Cache do desenho. GetComponentInChildren procura neste objeto e nos filhos: os dois ficam no View.
private void Awake()
{
_animator = GetComponentInChildren<SpriteAnimator>();
_view = GetComponentInChildren<IsoView>();
}

// Chamado pelo EnemyPool. Os multiplicadores são a escalada por Wave (Seção 4.3); 1 = valores da Wave 1.
// Zera TODO o estado: o objeto pode estar sendo reaproveitado de um inimigo que já morreu.
// "internal": só código da assembly Armageddon chama (na prática, o EnemyPool).
internal void Spawn(EnemyDefinition definition, Vector2 position, float hitpointsMultiplier, float damageMultiplier,
PlanetHealth planet, EnemyPool pool)
{
_definition = definition;
_movement = definition.Movement;
_planet = planet;
_pool = pool;
_radialPosition = position;
_hitpoints = definition.Hitpoints * hitpointsMultiplier;
_damage = definition.Damage * damageMultiplier;
_age = 0f;
_alive = true;

// Variação visual sorteada no spawn (Seção 6.2). O prefab tem "Random Start Frame" marcado,
// para os inimigos não piscarem todos em sincronia.
var variant = definition.Variants[Random.Range(0, definition.Variants.Count)];
_animator.SetFrames(variant.frames, definition.Fps, true);
_view.SetShadowRadius(definition.Radius); // a sombra no grid tem o tamanho deste tipo (Seção 6.2)

ApplyPosition();
TargetRegistry.Register(this); // a partir de agora os Satellites enxergam este inimigo
}

// Anda em direção ao centro e confere se chegou ao planeta.
private void Update()
{
if (!_alive) return;
float deltaTime = Time.deltaTime;
if (deltaTime <= 0f) return; // pausado

_age += deltaTime;
Vector2 toCenter = WorldLayout.PlanetCenter - _radialPosition;
float distance = toCenter.magnitude;

// Tocou o planeta (Seção 4.4): causa dano e some, sem Shards.
if (distance <= WorldLayout.PlanetRadius)
{
_planet.TakeDamage(_damage);
Die(EnemyExit.ReachedPlanet);
return;
}

// toCenter / distance = direção (vetor de tamanho 1). O Min impede de "passar do centro" num frame longo.
_radialPosition += toCenter / distance * Mathf.Min(_definition.Speed * deltaTime, distance);
ApplyPosition();
}

// Cumpre ITarget.TakeDamage: chamado pelo Projectile ao acertar.
public void TakeDamage(float amount, bool isCritical)
{
if (!_alive) return;
_hitpoints -= amount;
if (_hitpoints <= 0f) Die(EnemyExit.Killed);
}

// Calcula a posição real = posição na linha reta + desvio lateral, e move o transform no CHÃO.
// O IsoView do filho View projeta essa posição na tela (Fase 2).
// Estratégia: separar "avanço" (radial) de "desvio" (lateral) garante que o zigue-zague nunca atrase
// nem adiante a chegada ao planeta.
private void ApplyPosition()
{
Vector2 toCenter = WorldLayout.PlanetCenter - _radialPosition;
Vector2 forward = toCenter.sqrMagnitude > 0f ? toCenter.normalized : Vector2.zero;
// (-y, x) é o vetor "forward" girado 90°: a direção de lado.
Vector2 side = new Vector2(-forward.y, forward.x);
Position = _radialPosition + side * _movement.LateralOffset(_age);
transform.position = new Vector3(WorldLayout.SnapToPixel(Position.x), WorldLayout.SnapToPixel(Position.y), 0f);
}

// Único caminho de saída: marca como morto, sai do registro de alvos e avisa o pool
// (que toca a explosão, dispara os eventos e desliga o objeto).
private void Die(EnemyExit exit)
{
_alive = false;
TargetRegistry.Unregister(this);
_pool.HandleEnemyGone(this, exit);
}

// OnDisable: chamado quando o objeto é desligado ou destruído.
// Segurança: se a cena for descarregada com o inimigo vivo, ele sai do registro.
private void OnDisable()
{
if (!_alive) return;
_alive = false;
TargetRegistry.Unregister(this);
}
}
}

Passo 5 — Criar e reaproveitar inimigos: EnemyPool​

// Caminho: Assets/_Project/Scripts/Enemies/EnemyPool.cs
using System;
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;
using UnityEngine.Pool;

namespace Armageddon.Enemies
{
// POR QUE: (1) até 150 inimigos na tela: criar/destruir cada um travaria o navegador (ver ProjectilePool);
// (2) vários sistemas precisam saber quando um inimigo morreu (Shards, estatísticas, conquistas).
// ESTRATÉGIA: o pool é a "porta de entrada e de saída" dos inimigos. Spawn cria (incluindo cachos do
// Swarmer); HandleEnemyGone recebe o aviso do Enemy, conta, toca a explosão e dispara os eventos
// públicos. Assim os outros sistemas escutam UM objeto em vez de cada inimigo.
// Cria os inimigos comuns (com cachos, no caso do Swarmer), reaproveita-os e avisa quando cada um some.
public sealed class EnemyPool : MonoBehaviour
{
[SerializeField] private Enemy _prefab;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private ExplosionPool _explosions;
[SerializeField] private int _prewarm = 64;

private ObjectPool<Enemy> _pool;

// Inimigos vivos agora. A Fase 5 usa para o limite de 150 na tela e para o Wave Clear.
public int AliveCount { get; private set; }

public event Action<Enemy> EnemyKilled; // destruído por um Satellite: vale Shards (Fase 6)
public event Action<Enemy> EnemyReachedPlanet; // kamikaze: já causou o dano; não vale Shards

// Monta e aquece o pool (mesma estratégia do ProjectilePool).
private void Awake()
{
_pool = new ObjectPool<Enemy>(
createFunc: () => Instantiate(_prefab, transform),
actionOnGet: e => e.gameObject.SetActive(true),
actionOnRelease: e => e.gameObject.SetActive(false),
actionOnDestroy: e => Destroy(e.gameObject),
collectionCheck: false,
defaultCapacity: _prewarm,
maxSize: 256);

var warm = new Enemy[_prewarm];
for (int i = 0; i < _prewarm; i++) warm[i] = _pool.Get();
for (int i = 0; i < _prewarm; i++) _pool.Release(warm[i]);
}

// Um "slot de spawn" (Seção 4.3): 1 inimigo, ou um cacho de ClusterSize (Swarmer = 8) espalhado em volta do ponto.
// Parâmetros com "= 1f" são opcionais: as ferramentas de teste chamam sem escalada.
public void Spawn(EnemyDefinition definition, Vector2 position, float hitpointsMultiplier = 1f, float damageMultiplier = 1f)
{
int count = Mathf.Max(1, definition.ClusterSize);
for (int i = 0; i < count; i++)
{
// insideUnitCircle = ponto aleatório dentro de um círculo de raio 1.
Vector2 p = count == 1 ? position : position + UnityEngine.Random.insideUnitCircle * definition.ClusterSpread;
AliveCount++;
_pool.Get().Spawn(definition, p, hitpointsMultiplier, damageMultiplier, _planet, this);
}
}

// Chamado pelo próprio Enemy ao sair de cena.
internal void HandleEnemyGone(Enemy enemy, EnemyExit exit)
{
AliveCount--;
// A explosão aparece sempre: destruído ou batendo no planeta.
_explosions.Play(enemy.Definition.Explosion, enemy.Position);

if (exit == EnemyExit.Killed) EnemyKilled?.Invoke(enemy);
else if (exit == EnemyExit.ReachedPlanet) EnemyReachedPlanet?.Invoke(enemy);

// Devolve por último: os ouvintes dos eventos acima ainda podem ler os dados do inimigo.
_pool.Release(enemy);
}
}
}

Passo 6 — Teclas de teste: EnemyDevTools​

// Caminho: Assets/_Project/Scripts/Enemies/EnemyDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.World;
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Enemies
{
// POR QUE: antes das Waves (Fase 5), precisamos criar inimigos à mão para testar movimento, combate e morte.
// ESTRATÉGIA: componente de cena com as 4 definições arrastadas no Inspector; lê teclas e chama o
// EnemyPool. Também escuta os eventos do pool para mostrar no Console que eles disparam certo.
// G = Grunt · C = Scout · V = cacho de Swarmers · B = Brute, todos no mouse · R = 10 inimigos sorteados na borda (12 u).
// Mesma regra do CombatDevTools: a classe existe em todo build; campos e Update, só em desenvolvimento.
public sealed class EnemyDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private EnemyPool _pool;
[SerializeField] private EnemyDefinition _grunt;
[SerializeField] private EnemyDefinition _scout;
[SerializeField] private EnemyDefinition _swarmer;
[SerializeField] private EnemyDefinition _brute;

// Par OnEnable/OnDisable para inscrever/desinscrever eventos: padrão seguro para componentes de cena
// (se o objeto for desligado, para de ouvir).
private void OnEnable()
{
_pool.EnemyKilled += HandleEnemyKilled;
_pool.EnemyReachedPlanet += HandleEnemyReachedPlanet;
}

private void OnDisable()
{
_pool.EnemyKilled -= HandleEnemyKilled;
_pool.EnemyReachedPlanet -= HandleEnemyReachedPlanet;
}

// Lê as teclas de teste.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (Mouse.current != null && Camera.main != null)
{
// O mouse está na vista; o inimigo nasce no ponto do chão embaixo dele (ver DevShortcuts, F4).
Vector2 mouse = IsoProjection.ViewToGround(Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue()));
if (keyboard.gKey.wasPressedThisFrame) _pool.Spawn(_grunt, mouse);
if (keyboard.cKey.wasPressedThisFrame) _pool.Spawn(_scout, mouse);
if (keyboard.vKey.wasPressedThisFrame) _pool.Spawn(_swarmer, mouse);
if (keyboard.bKey.wasPressedThisFrame) _pool.Spawn(_brute, mouse);
}

if (keyboard.rKey.wasPressedThisFrame)
{
var all = new[] { _grunt, _scout, _swarmer, _brute };
for (int i = 0; i < 10; i++)
{
// Ângulo aleatório (em radianos) → ponto no círculo de spawn.
float angle = Random.Range(0f, 2f * Mathf.PI);
var edge = WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * WorldLayout.SpawnRadius;
_pool.Spawn(all[Random.Range(0, all.Length)], edge);
}
Debug.Log($"[Dev] Inimigos vivos: {_pool.AliveCount}");
}
}

// Respostas aos eventos do pool: só registram no Console.
private void HandleEnemyKilled(Enemy enemy)
{
Debug.Log($"[Dev] {enemy.Definition.name} destruído (+{enemy.Definition.Shards} Shards na Fase 6). Vivos: {_pool.AliveCount}");
}

private void HandleEnemyReachedPlanet(Enemy enemy)
{
Debug.Log($"[Dev] {enemy.Definition.name} atingiu o planeta. Vivos: {_pool.AliveCount}");
}
#endif
}
}

Passo 7 — Os 4 assets de inimigo​

Em Assets/_Project/Data/Enemies/, botão direito → Create → Armageddon → Enemy Definition, uma vez para cada inimigo. Preencha com os valores das Seções 4.3, 4.4 e 6.4:

AssetHitpointsDamageSpeedShardsRadiusMovementUnlock WaveSpawn WeightCluster SizeVariantsFpsExplosion
Enemy_Grunt1050.810.5Straight15012 variações: células 0–3 e 4–7 de SPR_Enemy_Grunt6Small
Enemy_Scout63210.5ZigZag (0.5 / 1.5)32511 variação: células 0–3 de SPR_Enemy_Scout12Small
Enemy_Swarmer321.20.50.25Straight61581 variação: células 0–1 de SPR_Enemy_Swarmer4Small
Enemy_Brute80200.540.75Straight81014 variações de 1 frame: células 0, 1, 2 e 3 de SPR_Enemy_Brute1Big

Na lista Variants, clique + uma vez por variação e arraste as células para o Frames de cada uma. No Brute, cada variação tem um frame só: o SpriteAnimator fica mostrando essa imagem parada.

Passo 8 — Prefab e cena​

  1. Prefab do inimigo: objeto vazio Enemy com o componente Enemy. Dentro dele, um filho vazio View com:

    • Sprite Renderer: qualquer célula do Grunt (só para ver o prefab); Sorting Layer World.
    • Sprite Animator: Frames vazio (o Enemy preenche ao nascer); Random Start Frame marcado.
    • Iso View: Shadow Radius 0 (o Enemy acerta o tamanho da sombra ao nascer, pelo Radius da definição).

    Arraste para Prefabs/Gameplay/ e apague da cena.

  2. Na cena Gameplay, em [Systems]:

    • ExplosionPool, com o componente ExplosionPool. Em Explosions, crie 2 itens: Small com as 5 células de Art/VFX/SPR_VFX_Explosion_Small e Fps 15; e Big com as 5 de SPR_VFX_Explosion_Big e Fps 15. A da Mothership entra na Fase 7.
    • EnemyPool, com o componente EnemyPool: Prefab = Enemy; Planet = o objeto Planet da cena; Explosions = o ExplosionPool.
    • EnemyDevTools, com o componente EnemyDevTools: Pool = o EnemyPool, e os 4 assets de inimigo.

Passo 9 — Commit​

git add . e git commit -m "Phase 4: enemy definitions, movement, pooling, kamikaze, explosions".

✅ Checkpoint:

  • R faz nascer 10 inimigos fora da tela, e todos andam em direção ao planeta, vindos de qualquer direção do losango. Cada um tem a sua sombra no grid, e um inimigo que passa atrás do planeta some atrás dele por um instante. Nenhum pisca em sincronia com os outros.
  • Cada tipo anda do seu jeito: o Grunt em linha reta a 0,8 u/s, o Scout rápido e em zigue-zague, o Swarmer em cacho de 8, e o Brute bem devagar. Os Grunts aparecem com as 2 variações visuais, e os Brutes com as 4.
  • Um inimigo que chega ao planeta explode na borda dele e tira HP conforme a tabela: Grunt 5, Scout 3, Swarmer 2 cada, Brute 20. O Console mostra atingiu o planeta, e F6 mostra o HP atual.
  • No Quadrant do Satellite e dentro do alcance, os inimigos são destruídos: o Grunt com 1 disparo, o Brute com 8 (e explosão maior). O Console mostra destruído (+1 Shards…).
  • G, C, V e B criam cada tipo no mouse. Um Brute criado dentro do alcance mostra, pelos disparos, que precisa de 8 tiros.
  • Esc congela os inimigos, as explosões e a órbita.
  • Na janela Hierarchy, EnemyPool já tem 64 inimigos desativados desde o início. Depois de muitos R, o número de objetos para de crescer: eles são reaproveitados.

Problemas comuns:

  • ArgumentOutOfRangeException no Enemy.Spawn: a EnemyDefinition está com a lista Variants vazia.
  • O inimigo aparece sem imagem: a variação tem o Frames vazio, ou o Fps está em 0.
  • Os inimigos passam pelo planeta sem dar dano: o campo Planet do EnemyPool está vazio (o Console mostra um NullReferenceException).
  • Os Satellites não atiram nos inimigos, só nos alvos de teste: o Enemy precisa se registrar no TargetRegistry no Spawn. Confira se o código está igual ao do Passo 4.
  • Os inimigos piscam todos juntos: falta marcar Random Start Frame no SpriteAnimator do prefab.
  • NullReferenceException no Enemy.Awake ou no Spawn: o prefab está sem o filho View, ou o View está sem o SpriteAnimator ou o IsoView.
  • As explosões aparecem longe de onde o inimigo morreu: o ExplosionPool.Play está colocando a posição do chão direto no transform. Ela precisa passar por IsoProjection.GroundToView, como no Passo 3.
  • A explosão fica parada no último frame: o ExplosionPool devolve a explosão ao pool no evento Finished, que só dispara com Loop desmarcado, e é por isso que o Play usa loop: false.

Próxima fase: Fase 5 — Waves. Ela coloca o WaveDirector para decidir quantos inimigos nascem, de que tipo, de quais Spawn Sectors e com qual escalada, com o aviso de 2 s na borda e o Wave Clear.


Fase 5 — Waves​

Objetivo desta fase: a run acontece sozinha. Um aviso de 2 s na borda da tela mostra de quais Spawn Sectors a próxima Wave vem; os inimigos nascem espalhados nos primeiros 20 s, com a composição e a escalada da Seção 4.3; a próxima Wave começa quando o timer acaba ou quando a tela esvazia; e o Wave Clear paga todas as Waves pendentes. O pagamento em Shards entra na Fase 6, e a Mothership (Boss Wave) na Fase 7.

Conceitos novos:

  • Máquina de estados simples: o WaveDirector está sempre numa de três fases: Idle (sem run), Warning (os 2 s de aviso) e Running (a Wave rodando). Cada fase tem a sua regra de saída. Assim, o código de "o que fazer agora" fica num lugar só e fácil de ler.
  • Fila de spawn: ao começar, a Wave coloca todos os seus slots numa fila, cada um com o seu horário de nascer. A cada frame, nasce quem já está na hora e cabe no limite de 150 inimigos. Um slot que não coube fica na fila e nasce depois, mesmo que outra Wave já tenha começado: é o "o spawn espera" da Seção 4.3.
  • Sorteio com pesos: cada tipo de inimigo tem um peso (Grunt 50, Scout 25…). O sorteio soma os pesos dos tipos já liberados, sorteia um número nessa soma e vê em qual faixa ele caiu. Os pesos ficam "renormalizados" sem nenhuma conta extra.
  • Dados de balanceamento separados do código (WaveBalance): todos os números da Seção 4.3 ficam num asset. O ajuste depois dos playtests é editar esse asset, não recompilar.

Passo 1 — Os números das Waves: WaveBalance​

// Caminho: Assets/_Project/Scripts/Waves/WaveBalance.cs
using System.Collections.Generic;
using Armageddon.Enemies;
using UnityEngine;

namespace Armageddon.Waves
{
// POR QUE: o ritmo do jogo (duração das Waves, quantos inimigos, quanto eles crescem) vai ser
// ajustado muitas vezes depois dos playtests. Esses números não podem estar espalhados no código.
// ESTRATÉGIA: um ScriptableObject (ver EnemyDefinition) que guarda os números E as fórmulas pequenas
// que dependem deles (SlotsFor, HitpointsMultiplier...). O WaveDirector só pergunta; nunca faz conta
// de balanceamento sozinho.
// Todos os números da Seção 4.3 (valores iniciais, ajustar com playtests).
[CreateAssetMenu(menuName = "Armageddon/Wave Balance", fileName = "WaveBalance")]
public sealed class WaveBalance : ScriptableObject
{
[Header("Tempo (s)")]
[SerializeField] private float _waveDuration = 30f;
[SerializeField] private float _bossWaveDuration = 60f;
[SerializeField] private float _warningDuration = 2f;
[SerializeField] private float _spawnWindow = 20f; // os slots nascem espalhados nestes primeiros segundos

[Header("Tamanho: slots = min(base + porWave·w, máximo)")]
[SerializeField] private int _baseSlots = 6;
[SerializeField] private int _slotsPerWave = 2;
[SerializeField] private int _maxSlots = 60;
[SerializeField] private int _maxAlive = 150; // limite de inimigos na tela

[Header("Escalada: base × crescimento^(w−1)")]
[SerializeField] private float _hitpointsGrowth = 1.09f;
[SerializeField] private float _damageGrowth = 1.06f;

[Header("Spawn Sectors: 1 setor até a Wave anterior à primeira abaixo")]
[SerializeField] private int _twoSectorsFromWave = 5;
[SerializeField] private int _threeSectorsFromWave = 15;

[Header("Boss Wave e Wave Clear")]
[SerializeField] private int _bossEvery = 10;
[SerializeField] private int _clearBonusBase = 5; // bônus = (base + w) × (1 + Resource per Wave)

[Header("Inimigos comuns sorteáveis")]
[SerializeField] private List<EnemyDefinition> _enemies = new List<EnemyDefinition>();

public float WaveDuration => _waveDuration;
public float BossWaveDuration => _bossWaveDuration;
public float WarningDuration => _warningDuration;
public float SpawnWindow => _spawnWindow;
public int MaxAlive => _maxAlive;
public IReadOnlyList<EnemyDefinition> Enemies => _enemies;

// Wave 10, 20, 30...: "%" é o resto da divisão; resto 0 = múltiplo de _bossEvery.
public bool IsBossWave(int wave) => wave > 0 && wave % _bossEvery == 0;
// Quantos slots de spawn a Wave tem (Seção 4.3): min(6 + 2·w, 60).
public int SlotsFor(int wave) => Mathf.Min(_baseSlots + _slotsPerWave * wave, _maxSlots);
// Crescimento exponencial: Mathf.Pow(1,09, w−1) = 1,09 multiplicado por ele mesmo (w−1) vezes. Wave 1 = ×1.
public float HitpointsMultiplier(int wave) => Mathf.Pow(_hitpointsGrowth, wave - 1);
public float DamageMultiplier(int wave) => Mathf.Pow(_damageGrowth, wave - 1);

// Quantos Spawn Sectors a Wave sorteia (mais setores = ataque de mais direções).
public int SectorCountFor(int wave)
{
if (wave >= _threeSectorsFromWave) return 3;
if (wave >= _twoSectorsFromWave) return 2;
return 1;
}

// Bônus de uma Wave paga no Wave Clear, antes do Stat Resource per Wave (aplicado na Fase 6).
public int ClearBonus(int wave) => _clearBonusBase + wave;
}
}

Passo 2 — O diretor das Waves: WaveDirector​

// Caminho: Assets/_Project/Scripts/Waves/WaveDirector.cs
using System;
using System.Collections.Generic;
using Armageddon.Enemies;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Waves
{
// POR QUE: a run precisa de um "maestro" que decida quando cada Wave começa, quais inimigos nascem,
// de onde, com que força, e quando a tela foi limpa (Wave Clear). Sem isso, o jogo é só um planeta parado.
// ESTRATÉGIA:
// - Máquina de estados com 3 fases (Idle → Warning → Running → Warning...). O Update olha a fase atual
// e decide só a regra de saída dela.
// - Ao começar uma Wave, TODOS os slots são decididos de uma vez (tipo, posição, escalada, horário) e
// postos numa fila; a cada frame nasce quem já está na hora e cabe no limite de 150.
// - Guarda a lista de Waves "não pagas"; o Wave Clear paga todas de uma vez.
// - Não cria inimigos diretamente: pede ao EnemyPool. Não paga Shards: só dispara WaveCleared
// (a economia da Fase 6 escuta). Números vêm do WaveBalance.
// Conduz a run Wave a Wave (Seção 4.3): aviso de 2 s, spawn espalhado, escalada, fim da Wave e Wave Clear.
public sealed class WaveDirector : MonoBehaviour
{
public const int SectorCount = 6; // 6 arcos de 60°
public const float SectorArc = 60f;

// Enum privado: as fases só importam dentro desta classe.
private enum Phase { Idle, Warning, Running }

// Um slot esperando para nascer: tipo, posição e escalada já decididos quando a Wave começou.
// struct (tipo de valor) porque é só um pacote de dados pequeno, guardado aos montes na fila.
private struct PendingSpawn
{
public EnemyDefinition Definition;
public Vector2 Position;
public float HitpointsMultiplier;
public float DamageMultiplier;
public float DueTime; // em "tempo de run" (_runTime), não em tempo da Wave
}

[SerializeField] private WaveBalance _balance;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private bool _autoStart = true; // a Fase 8 desliga: o RunController chama StartRun

// Queue = fila (o primeiro que entra é o primeiro que sai). Como os horários são crescentes,
// basta olhar a frente da fila.
private readonly Queue<PendingSpawn> _queue = new Queue<PendingSpawn>();
private readonly List<int> _sectors = new List<int>(); // Spawn Sectors sorteados para a Wave atual
private readonly List<int> _unpaidWaves = new List<int>(); // Waves começadas e ainda sem Wave Clear
private readonly List<EnemyDefinition> _unlocked = new List<EnemyDefinition>(); // reaproveitada para não alocar
private Phase _phase = Phase.Idle;
private float _phaseTime; // segundos desde que a fase atual começou
private float _runTime; // segundos desde o começo da run (relógio da fila de spawn)
private float _duration; // duração da Wave atual (normal ou Boss)
private int _externalAlive; // inimigos vivos fora do EnemyPool (a Mothership)

public int CurrentWave { get; private set; }
public IReadOnlyList<int> CurrentSectors => _sectors; // os Spawn Sectors da Wave atual (a Mothership entra por um deles)
public bool IsBossWave => _balance.IsBossWave(CurrentWave);
public bool IsWarning => _phase == Phase.Warning;
public float TimeRemaining => _phase == Phase.Running ? Mathf.Max(0f, _duration - _phaseTime) : 0f;
public int AliveCount => _enemies.AliveCount + _externalAlive;

public event Action<int, IReadOnlyList<int>> WaveWarning; // Wave que vem e os seus Spawn Sectors
public event Action<int> WaveStarted;
public event Action<int> BossWaveStarted; // a Fase 7 cria a Mothership aqui
public event Action<IReadOnlyList<int>, bool> WaveCleared; // Waves pagas; true = antes do timer

// Até a Fase 8, a run começa sozinha ao abrir a cena.
private void Start()
{
if (_autoStart) StartRun();
}

// Zera o estado e começa pelo aviso da Wave 1.
public void StartRun()
{
_queue.Clear();
_unpaidWaves.Clear();
_runTime = 0f;
BeginWarning(1);
}

// Para tudo (fim de run). Os inimigos já em cena continuam; quem encerra a run cuida deles.
public void StopRun()
{
_phase = Phase.Idle;
_queue.Clear();
}

// Inimigos vivos que não vêm do EnemyPool (a Mothership, Fase 7). Contam para o Wave Clear.
public void AddExternalAlive(int delta)
{
_externalAlive = Mathf.Max(0, _externalAlive + delta);
}

// O "coração" da máquina de estados: avança relógios, faz nascer quem está na hora,
// confere o Wave Clear e troca de fase quando a regra de saída é cumprida.
private void Update()
{
float deltaTime = Time.deltaTime;
if (_phase == Phase.Idle || deltaTime <= 0f) return;

_phaseTime += deltaTime;
_runTime += deltaTime;
SpawnDue();

if (TryWaveClear()) return;

if (_phase == Phase.Warning && _phaseTime >= _balance.WarningDuration)
{
BeginWave();
}
else if (_phase == Phase.Running && _phaseTime >= _duration)
{
// Timer acabou com inimigos vivos: esta Wave continua "não paga" até o próximo Wave Clear.
BeginWarning(CurrentWave + 1);
}
}

// Entra na fase de aviso da Wave "wave": sorteia os setores e avisa (o SpawnSectorIndicator mostra as setas).
private void BeginWarning(int wave)
{
CurrentWave = wave;
_phase = Phase.Warning;
_phaseTime = 0f;
ChooseSectors(_balance.SectorCountFor(wave));
WaveWarning?.Invoke(wave, _sectors);
}

// Entra na fase Running: define a duração, marca a Wave como "não paga" e enfileira os slots.
private void BeginWave()
{
_phase = Phase.Running;
_phaseTime = 0f;
int wave = CurrentWave;
bool boss = _balance.IsBossWave(wave);
_duration = boss ? _balance.BossWaveDuration : _balance.WaveDuration;
_unpaidWaves.Add(wave);

// Boss Wave: só a Mothership, sem inimigos comuns (Seção 4.5).
if (!boss) EnqueueSlots(wave);

WaveStarted?.Invoke(wave);
if (boss) BossWaveStarted?.Invoke(wave);
}

// Decide todos os slots da Wave e os coloca na fila, espalhados ao longo da janela de spawn (20 s).
private void EnqueueSlots(int wave)
{
// 1) Quais inimigos já foram liberados nesta Wave, e a soma dos pesos deles.
_unlocked.Clear();
float totalWeight = 0f;
foreach (var definition in _balance.Enemies)
{
if (definition.UnlockWave > wave) continue;
_unlocked.Add(definition);
totalWeight += definition.SpawnWeight;
}
if (_unlocked.Count == 0) return;

// 2) Quantos slots, de quanto em quanto tempo, e a escalada desta Wave.
int slots = _balance.SlotsFor(wave);
float interval = _balance.SpawnWindow / slots;
float hitpoints = _balance.HitpointsMultiplier(wave);
float damage = _balance.DamageMultiplier(wave);

// 3) Um PendingSpawn por slot. "new PendingSpawn { ... }" preenche os campos na criação.
for (int i = 0; i < slots; i++)
{
_queue.Enqueue(new PendingSpawn
{
Definition = PickWeighted(totalWeight),
Position = RandomPointInSectors(),
HitpointsMultiplier = hitpoints,
DamageMultiplier = damage,
DueTime = _runTime + i * interval,
});
}
}

// Nasce quem já está na hora, respeitando o limite de inimigos na tela. Um cacho de Swarmers conta como 8.
// Peek olha o primeiro da fila sem tirar; Dequeue tira. Se o primeiro não pode nascer, ninguém atrás dele nasce.
private void SpawnDue()
{
while (_queue.Count > 0)
{
var next = _queue.Peek();
if (next.DueTime > _runTime) return;
if (AliveCount + Mathf.Max(1, next.Definition.ClusterSize) > _balance.MaxAlive) return; // o spawn espera

_queue.Dequeue();
_enemies.Spawn(next.Definition, next.Position, next.HitpointsMultiplier, next.DamageMultiplier);
}
}

// Wave Clear (Seção 4.3): a tela ficou vazia e não há ninguém esperando para nascer.
// Paga TODAS as Waves pendentes, inclusive as que já tinham passado do timer.
// Devolve true se aconteceu (o Update então não confere mais nada neste frame).
private bool TryWaveClear()
{
if (_unpaidWaves.Count == 0 || _queue.Count > 0 || AliveCount > 0) return false;

bool beforeTimer = _phase == Phase.Running && _phaseTime < _duration;
// ToArray faz uma cópia: quem recebe o evento não é afetado pelo Clear logo abaixo.
var paid = _unpaidWaves.ToArray();
_unpaidWaves.Clear();
WaveCleared?.Invoke(paid, beforeTimer);

// Na fase Running, a tela vazia também começa a próxima Wave (Seção 4.3).
if (_phase == Phase.Running) BeginWarning(CurrentWave + 1);
return true;
}

// Sorteio com pesos: sorteia um número entre 0 e a soma dos pesos e "desconta" o peso de cada tipo
// até o número ficar negativo. Tipos com peso maior ocupam uma faixa maior, então saem mais.
private EnemyDefinition PickWeighted(float totalWeight)
{
float roll = UnityEngine.Random.value * totalWeight;
foreach (var definition in _unlocked)
{
roll -= definition.SpawnWeight;
if (roll < 0f) return definition;
}
return _unlocked[_unlocked.Count - 1]; // proteção contra arredondamento
}

// Sorteia 'count' setores diferentes entre os 6 (embaralhamento parcial).
// Estratégia (Fisher–Yates parcial): lista 0..5; para as primeiras "count" posições, troca cada uma
// com uma posição aleatória à frente; depois corta o resto. Garante setores sem repetição.
private void ChooseSectors(int count)
{
_sectors.Clear();
for (int i = 0; i < SectorCount; i++) _sectors.Add(i);
for (int i = 0; i < count; i++)
{
int j = UnityEngine.Random.Range(i, SectorCount); // Range com int: o máximo NÃO é incluído
(_sectors[i], _sectors[j]) = (_sectors[j], _sectors[i]); // troca os dois valores (sintaxe de tupla)
}
_sectors.RemoveRange(count, SectorCount - count);
}

// Um ponto aleatório num dos setores sorteados, a 12 u do centro (fora da tela).
private Vector2 RandomPointInSectors()
{
int sector = _sectors[UnityEngine.Random.Range(0, _sectors.Count)];
float angle = (sector * SectorArc + UnityEngine.Random.Range(0f, SectorArc)) * Mathf.Deg2Rad;
return WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * WorldLayout.SpawnRadius;
}

#if UNITY_EDITOR || DEVELOPMENT_BUILD
// Só para teste: faz o timer da Wave atual acabar agora.
public void DevEndWaveTimer()
{
if (_phase == Phase.Running) _phaseTime = _duration;
}
#endif
}
}

Setores e setas: o setor k cobre de 60·k° a 60·k + 60°; o centro dele fica em 30 + 60·k°. São exatamente as 6 direções das setas de SPR_UI_SpawnSectorArrow (linha k da spritesheet, Seção 6.4).

Passo 3 — O aviso na borda: SpawnSectorIndicator​

// Caminho: Assets/_Project/Scripts/Waves/SpawnSectorIndicator.cs
using System;
using System.Collections.Generic;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Waves
{
// POR QUE: os inimigos nascem fora da tela (12 u). Sem aviso, o jogador não teria como se preparar
// (ex.: trocar a Target Priority ou comprar Upgrades) para a direção do ataque.
// ESTRATÉGIA: componente só visual que escuta o WaveDirector. Cria de antemão uma seta por setor
// (6 no total) e só liga/desliga as necessárias. A posição é recalculada a cada frame porque a câmera
// pode deslizar (gaveta) e a tela pode ter qualquer proporção.
// Durante os 2 s de aviso, mostra uma seta piscando na borda da tela para cada Spawn Sector sorteado (Seção 6.2).
// A seta fica onde a linha "planeta → centro do setor", JÁ PROJETADA na tela, cruza a borda, e acompanha a câmera.
// Como a projeção muda o ângulo de cada setor, cada setor usa a seta pré-desenhada mais próxima (ArrowRowFor).
public sealed class SpawnSectorIndicator : MonoBehaviour
{
// Pacote do Inspector: os frames de uma seta (o Unity não serializa Sprite[][], ver SpriteVariant).
[Serializable]
private sealed class ArrowFrames
{
public Sprite[] frames; // aceso e apagado
}

[SerializeField] private WaveDirector _waves;
[SerializeField] private ArrowFrames[] _arrows = new ArrowFrames[WaveDirector.SectorCount]; // índice = linha da spritesheet (direção na TELA)
[SerializeField] private float _fps = 4f;
[SerializeField] private float _edgeMargin = 1f; // distância da seta até a borda, em u
[SerializeField] private string _sortingLayer = "VFX";
[SerializeField] private int _sortingOrder = 100;

private readonly SpriteAnimator[] _views = new SpriteAnimator[WaveDirector.SectorCount];

// Cria as 6 setas desligadas, uma vez só: uma por SETOR, cada uma com os frames da linha mais próxima.
private void Awake()
{
for (int i = 0; i < _views.Length; i++)
{
var go = new GameObject($"Arrow {i}");
go.transform.SetParent(transform, false);
var renderer = go.AddComponent<SpriteRenderer>();
renderer.sortingLayerName = _sortingLayer;
renderer.sortingOrder = _sortingOrder;
_views[i] = go.AddComponent<SpriteAnimator>();
_views[i].SetFrames(_arrows[ArrowRowFor(i)].frames, _fps, true);
go.SetActive(false);
}
}

// Direção do centro do setor no CHÃO, projetada na tela e normalizada (tamanho 1).
private static Vector2 ViewDirection(int sector)
{
float angle = (sector * WaveDirector.SectorArc + WaveDirector.SectorArc * 0.5f) * Mathf.Deg2Rad;
var ground = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
// A projeção é linear: projetar uma direção é projetar o ponto "1 u naquela direção".
return IsoProjection.GroundToView(ground).normalized;
}

// Qual das 6 setas desenhadas (30°, 90°, 150°... na TELA) aponta mais perto da direção projetada.
// Ex.: o setor 0 (30° no chão) aparece a ~62° na tela, e usa a seta de 90° (linha 1).
// Mathf.Round(...) dá quantos "passos de 60°" a partir de 30°; Repeat traz para 0–5.
private static int ArrowRowFor(int sector)
{
Vector2 direction = ViewDirection(sector);
float degrees = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
return (int)Mathf.Repeat(Mathf.Round((degrees - 30f) / 60f), WaveDirector.SectorCount);
}

// Inscreve nos eventos do WaveDirector (e desinscreve no OnDisable).
// Funciona mesmo com a Wave 1 porque todos os OnEnable rodam antes do Start do WaveDirector,
// que é quem dispara o primeiro aviso.
private void OnEnable()
{
_waves.WaveWarning += HandleWaveWarning;
_waves.WaveStarted += HandleWaveStarted;
}

private void OnDisable()
{
_waves.WaveWarning -= HandleWaveWarning;
_waves.WaveStarted -= HandleWaveStarted;
}

// Aviso de Wave: acende só as setas dos setores sorteados.
private void HandleWaveWarning(int wave, IReadOnlyList<int> sectors)
{
HideAll();
foreach (int sector in sectors) _views[sector].gameObject.SetActive(true);
}

// A Wave começou: o aviso acabou.
private void HandleWaveStarted(int wave)
{
HideAll();
}

// Desliga todas as setas.
private void HideAll()
{
foreach (var view in _views) view.gameObject.SetActive(false);
}

// Reposiciona as setas acesas na borda da tela (LateUpdate: depois de a câmera se mover, ver CameraRig).
private void LateUpdate()
{
var view = Camera.main;
if (view == null) return;

// Retângulo visível da câmera, encolhido pela margem.
Vector2 cameraCenter = view.transform.position;
float halfHeight = view.orthographicSize - _edgeMargin;
float halfWidth = view.orthographicSize * view.aspect - _edgeMargin;
Vector2 origin = IsoProjection.GroundToView(WorldLayout.PlanetCenter); // o planeta, na vista

for (int i = 0; i < _views.Length; i++)
{
if (!_views[i].gameObject.activeSelf) continue;

// Direção do centro do setor i, já projetada na tela (os inimigos chegam por ela).
Vector2 direction = ViewDirection(i);

// Distância, ao longo da direção, até a primeira borda (esquerda/direita ou cima/baixo) da tela.
// Estratégia: calcula quanto andar até bater na borda vertical (tx) e na horizontal (ty);
// a menor das duas é a borda que a linha cruza primeiro.
float tx = direction.x > 0f ? (cameraCenter.x + halfWidth - origin.x) / direction.x
: direction.x < 0f ? (cameraCenter.x - halfWidth - origin.x) / direction.x
: float.MaxValue;
float ty = direction.y > 0f ? (cameraCenter.y + halfHeight - origin.y) / direction.y
: direction.y < 0f ? (cameraCenter.y - halfHeight - origin.y) / direction.y
: float.MaxValue;
Vector2 p = origin + direction * Mathf.Min(tx, ty);

_views[i].transform.position = new Vector3(WorldLayout.SnapToPixel(p.x), WorldLayout.SnapToPixel(p.y), 0f);
}
}
}
}

Passo 4 — Teclas de teste: WaveDevTools​

// Caminho: Assets/_Project/Scripts/Waves/WaveDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using System.Collections.Generic;
using System.Linq;
using Armageddon.Combat;
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Waves
{
// POR QUE: esperar 30 s por Wave para testar a Wave 15 é inviável; e o Wave Clear precisa ser testado à vontade.
// ESTRATÉGIA: componente de cena (mesma regra do CombatDevTools). Escuta os eventos do WaveDirector e
// os escreve no Console, para conferir a ordem aviso → início → clear.
// N = acaba o timer da Wave atual · K = destrói todos os inimigos (testa o Wave Clear). Registra os eventos no Console.
public sealed class WaveDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private WaveDirector _waves;

// Inscreve nos eventos do WaveDirector (e desinscreve no OnDisable).
private void OnEnable()
{
_waves.WaveWarning += HandleWaveWarning;
_waves.WaveStarted += HandleWaveStarted;
_waves.WaveCleared += HandleWaveCleared;
}

private void OnDisable()
{
_waves.WaveWarning -= HandleWaveWarning;
_waves.WaveStarted -= HandleWaveStarted;
_waves.WaveCleared -= HandleWaveCleared;
}

// Lê as teclas de teste.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.nKey.wasPressedThisFrame) _waves.DevEndWaveTimer();

if (keyboard.kKey.wasPressedThisFrame)
{
// Cópia da lista: cada alvo que morre sai do TargetRegistry durante o laço.
// (Alterar uma lista enquanto se percorre com foreach dá erro; ToList, do System.Linq, faz a cópia.)
foreach (var target in TargetRegistry.Alive.ToList()) target.TakeDamage(float.MaxValue, false);
}
}

// Respostas aos eventos: só escrevem no Console. string.Join junta a lista com ", ".
private void HandleWaveWarning(int wave, IReadOnlyList<int> sectors)
{
Debug.Log($"[Waves] Aviso: Wave {wave} vem dos setores {string.Join(", ", sectors)}");
}

private void HandleWaveStarted(int wave)
{
Debug.Log($"[Waves] Wave {wave} começou{(_waves.IsBossWave ? " (BOSS: a Mothership entra na Fase 7)" : "")}. Vivos: {_waves.AliveCount}");
}

private void HandleWaveCleared(IReadOnlyList<int> paidWaves, bool beforeTimer)
{
Debug.Log($"[Waves] Wave Clear{(beforeTimer ? " antes do timer" : "")}: paga(s) {string.Join(", ", paidWaves)}");
}
#endif
}
}

Passo 5 — O asset de balanceamento e a cena​

  1. Em Assets/_Project/Data/Balance/, Create → Armageddon → Wave Balance, com o nome WaveBalance. Os valores padrão já são os da Seção 4.3. Em Enemies, arraste os 4 assets da Fase 4 (Enemy_Grunt, Enemy_Scout, Enemy_Swarmer, Enemy_Brute).
  2. Na cena Gameplay:
    • Em [Systems], crie WaveDirector com o componente WaveDirector: Balance = WaveBalance; Enemies = o EnemyPool; Auto Start marcado.
    • Em [World], crie SpawnSectorIndicator com o componente SpawnSectorIndicator: Waves = o WaveDirector. Em Arrows (6 itens), coloque no item k as células 2k e 2k+1 de Art/UI/SPR_UI_SpawnSectorArrow: item 0 = células 0 e 1 (seta de 30° na tela), item 1 = células 2 e 3 (90°), e assim por diante até o item 5 = células 10 e 11 (330°). O código escolhe sozinho qual seta cada setor usa.
    • Em [Systems], crie WaveDevTools com o componente WaveDevTools e o WaveDirector em Waves.
  3. O EnemyDevTools da Fase 4 continua útil, mas a tecla R agora compete com as Waves: use-a só para testar o limite de tela.

Passo 6 — Commit​

git add . e git commit -m "Phase 5: waves, spawn sectors, scaling, screen cap, wave clear".

✅ Checkpoint:

  • Play na Gameplay: uma seta magenta pisca na borda da tela por 2 s (Console: Aviso: Wave 1 vem dos setores 3, por exemplo), some, e 8 Grunts (6 + 2 × 1) nascem daquele lado, espalhados ao longo de 20 s.
  • Sem nenhum Satellite por perto dos inimigos, a Wave 2 começa quando o timer de 30 s acaba. Com os Satellites destruindo todos, ela começa antes (Console: Wave Clear antes do timer: paga(s) 1).
  • Deixe o timer da Wave 1 acabar com inimigos vivos e aperte K durante a Wave 2, depois que todos os dela nascerem: o Console mostra Wave Clear…: paga(s) 1, 2. As duas são pagas juntas (Seção 4.3).
  • Scouts aparecem a partir da Wave 3, Swarmers da 6 e Brutes da 8 (use N para avançar rápido). A partir da Wave 5 aparecem 2 setas e, da 15, 3.
  • Com a gaveta aberta (F3), as setas continuam na borda visível da tela.
  • A seta aparece do lado de onde os inimigos entram na tela, apontando para o planeta. Com a projeção, os setores e as setas ficam assim: setor 0 (30° no chão) → seta de 90° (em cima, um pouco à direita); setor 1 → 150°; setor 2 → 210°; setor 3 → 270°; setor 4 → 330°; setor 5 → 30°.
  • Na Wave 10, o Console mostra (BOSS: a Mothership entra na Fase 7), e a Wave termina sozinha por não ter inimigos. É esperado até a Fase 7.
  • Várias vezes R (10 inimigos cada) até passar de 150 vivos: a Wave para de soltar inimigos e volta a soltar assim que alguns morrem.
  • Esc pausa o timer, o spawn e o piscar das setas.

Problemas comuns:

  • Nenhum inimigo nasce: a lista Enemies do WaveBalance está vazia, ou nenhum inimigo tem Unlock Wave ≤ 1.
  • Os inimigos nascem do lado oposto da seta, ou a seta aponta torto: a ordem das células no Arrows do SpawnSectorIndicator está trocada. O item k precisa ser a linha k da spritesheet (células 2k e 2k+1): é a direção na tela, e o ArrowRowFor faz a ponte com o setor.
  • A Wave nunca termina antes do timer, mesmo com a tela vazia: ainda há slots na fila, esperando a hora de nascer (os 20 s de spawn). O Wave Clear só acontece com a fila vazia e a tela vazia.
  • As setas aparecem no centro da tela: a Main Camera não está com a tag MainCamera (o Camera.main não a encontra).

Próxima fase: Fase 6 — Stats, Shards, Upgrades e gaveta. Ela transforma os inimigos destruídos e os Wave Clears em Shards e cria a gaveta de upgrades com os 13 Stats.


Fase 6 — Stats, Shards, Upgrades e gaveta​

Objetivo desta fase: destruir inimigos e fazer Wave Clear dá Shards; a gaveta na parte de baixo da tela mostra o saldo e as 4 abas; tocar numa aba abre os cards dos Stats daquela Track, e tocar num card compra um Upgrade sem pausar o jogo. Cada compra muda o jogo na hora: mais dano, mais alcance (o preenchimento do Quadrant cresce), mais HP. A aba Satellites troca a Target Priority de cada Satellite e compra novos Satellites (até 4).

Conceitos novos:

  • Definição × estado: o StatDefinition (asset) diz o que um Stat é (base, incremento, teto, custo). O RunStats guarda o nível de cada Stat nesta run. Os assets nunca mudam durante o jogo; só o estado muda, e ele é descartado no fim da run.
  • Modificadores: o Planet Core (Fase 9) não mexe nos níveis: ele entra como modificadores (StatModifier) que somam e multiplicam o valor. O cálculo fica num lugar só, seguindo a Seção 4.2: base + incremento × nível, limitado pelo teto, e depois modificado.
  • Composição (RunEconomy): um componente cria a economia da run (Stats, carteira e loja), escuta os eventos dos outros sistemas (inimigo destruído, Wave Clear) e empurra os valores novos para quem usa (Satellites e planeta). Os sistemas continuam sem se conhecer.
  • Canvas pixel-perfect: a UI é desenhada numa tela de referência de 320×180 e ampliada pelo mesmo fator inteiro da Pixel Perfect Camera (6× em 1080p). Assim, a UI tem o mesmo tamanho de pixel que o mundo.
  • UI por eventos: os cards não verificam o saldo a cada frame. Eles se atualizam quando a carteira ou o Stat avisam que mudaram (BalanceChanged, StatChanged).

Passo 1 — A definição de um Stat: StatDefinition, StatCatalog​

// Caminho: Assets/_Project/Scripts/Economy/StatDefinition.cs
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: cada Stat precisa de um identificador que o código use sem depender de nomes de asset.
// ESTRATÉGIA: enum; os assets StatDefinition dizem "eu sou o Stat X" por este id.
// Os 13 Stats da Seção 4.2.
public enum StatId
{
Damage, AttackSpeed, CriticalChance, CriticalFactor, AttackRange, Impetus, OrbitSpeed,
Hitpoints, Regeneration, DefenseAbsolute, DefenseRelative,
ResourceBonus, ResourcePerWave,
}

// Em qual Track (aba da gaveta) o Stat aparece.
public enum StatTrack { Offense, Defense, Utility }

// Como o valor aparece no card.
public enum StatFormat { Number, Percent, PerSecond, Multiplier, Units, DegreesPerSecond, PercentPerUnit }

// POR QUE: os 13 Stats seguem a mesma regra (base + incremento × nível, teto, custo crescente); só os
// números mudam. É a "definição" (o que o Stat É), separada do "estado" (em que nível ele está na run).
// ESTRATÉGIA: ScriptableObject (ver EnemyDefinition), um asset por Stat (Stat_Damage...). Nunca muda
// durante o jogo: quem guarda o nível é o RunStats. Também sabe se formatar para o card (Format).
// Um Stat (Seção 4.2). Percentuais são guardados como fração: 1,5 p.p. = 0,015; 0,4 %/u = 0,004.
[CreateAssetMenu(menuName = "Armageddon/Stat Definition", fileName = "Stat_")]
public sealed class StatDefinition : ScriptableObject
{
public const float CostGrowth = 1.15f; // custo = baseCost × 1,15^nível

[SerializeField] private StatId _id;
[SerializeField] private StatTrack _track;
[SerializeField] private string _displayName; // a Fase 11 troca por uma chave de Localization
[SerializeField] private Sprite _icon;
[SerializeField] private float _base; // valor no nível 0
[SerializeField] private float _perLevel; // quanto cada Upgrade soma
[SerializeField] private float _cap; // 0 = sem teto
[SerializeField] private int _baseCost = 5; // custo do primeiro Upgrade
[SerializeField] private StatFormat _format;

public StatId Id => _id;
public StatTrack Track => _track;
public string DisplayName => _displayName;
public Sprite Icon => _icon;
public float Base => _base;
public float PerLevel => _perLevel;
public bool HasCap => _cap > 0f;
public float Cap => _cap;
public int BaseCost => _baseCost;

// Transforma o número em texto para o card, conforme o formato do Stat (ex.: 0,015 → "1.5%").
// "{value:0.#}" = formatação do C#: até 1 casa decimal, sem zeros inúteis.
public string Format(float value)
{
return _format switch
{
StatFormat.Percent => $"{value * 100f:0.#}%",
StatFormat.PerSecond => $"{value:0.##}/s",
StatFormat.Multiplier => $"{value:0.#}x",
StatFormat.Units => $"{value:0.##}u",
StatFormat.DegreesPerSecond => $"{value:0}°/s",
StatFormat.PercentPerUnit => $"{value * 100f:0.#}%/u",
_ => $"{value:0.#}",
};
}
}
}
// Caminho: Assets/_Project/Scripts/Economy/StatCatalog.cs
using System.Collections.Generic;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: o jogo precisa de "todos os Stats" num lugar só, numa ordem definida (a ordem dos cards
// na gaveta), sem procurar assets pelo projeto.
// ESTRATÉGIA: um ScriptableObject que é só uma lista de StatDefinition arrastada no Inspector.
// A ordem da lista é a ordem de exibição.
// A lista dos 13 Stats, na ordem em que aparecem na gaveta.
[CreateAssetMenu(menuName = "Armageddon/Stat Catalog", fileName = "StatCatalog")]
public sealed class StatCatalog : ScriptableObject
{
[SerializeField] private List<StatDefinition> _stats = new List<StatDefinition>();

public IReadOnlyList<StatDefinition> Stats => _stats;

// Os Stats de uma Track (uma aba da gaveta), mantendo a ordem do catálogo.
// FindAll com lambda: "s => s.Track == track" = "fique com os s cuja Track é a pedida".
public List<StatDefinition> ForTrack(StatTrack track)
{
return _stats.FindAll(s => s.Track == track);
}
}
}

Passo 2 — Os Stats da run: StatModifier, RunStats​

// Caminho: Assets/_Project/Scripts/Economy/RunStats.cs
using System;
using System.Collections.Generic;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: o Planet Core (Fase 9) altera Stats sem ser Upgrade (ex.: "+30% de dano" o tempo todo).
// Se ele mexesse nos níveis, o custo dos Upgrades ficaria errado.
// ESTRATÉGIA: um ajuste separado, aplicado DEPOIS do cálculo por nível.
// Um ajuste vindo de fora dos Upgrades: o Planet Core (Fase 9).
// valor final = (valor dos Upgrades + add) × multiply
[Serializable]
public struct StatModifier
{
public StatId stat;
public float add;
public float multiply;

// Atalhos de criação: StatModifier.Add(StatId.Damage, 5) é mais legível que preencher os 3 campos.
public static StatModifier Add(StatId stat, float value) => new StatModifier { stat = stat, add = value, multiply = 1f };
public static StatModifier Multiply(StatId stat, float value) => new StatModifier { stat = stat, add = 0f, multiply = value };
}

// POR QUE: durante a run, cada Stat tem um nível que sobe com as compras; no fim, tudo zera.
// ESTRATÉGIA: classe C# pura (não é MonoBehaviour: não precisa de cena nem de Update), criada pelo
// RunEconomy a cada run. Guarda os níveis num Dictionary (StatId → nível) e calcula valores sob
// demanda. Avisa por evento (StatChanged) quando algo muda, para a UI e os Satellites se atualizarem.
// Nível e valor de cada Stat durante uma run. Zera a cada run (Seção 4.2: Upgrades valem só até o fim da run).
public sealed class RunStats
{
// Dictionary = tabela de consulta rápida "chave → valor".
private readonly Dictionary<StatId, StatDefinition> _definitions = new Dictionary<StatId, StatDefinition>();
private readonly Dictionary<StatId, int> _levels = new Dictionary<StatId, int>();
private readonly List<StatModifier> _modifiers = new List<StatModifier>();

public event Action<StatId> StatChanged;

// Monta as tabelas a partir das definições do catálogo, todos os níveis em 0.
public RunStats(IEnumerable<StatDefinition> definitions)
{
foreach (var definition in definitions)
{
_definitions[definition.Id] = definition;
_levels[definition.Id] = 0;
}
}

// Consultas rápidas: definição, nível atual e valor atual de um Stat.
public StatDefinition Definition(StatId id) => _definitions[id];
public int Level(StatId id) => _levels[id];
public float Value(StatId id) => ValueAtLevel(id, _levels[id]);

// Seção 4.2: base + incremento × nível, limitado pelo teto, e DEPOIS modificado pelo Planet Core.
// Recebe o nível como parâmetro para o card poder mostrar "valor atual → valor do próximo nível".
public float ValueAtLevel(StatId id, int level)
{
var definition = _definitions[id];
float value = definition.Base + definition.PerLevel * level;
if (definition.HasCap) value = Mathf.Min(value, definition.Cap);

// Soma todos os "add" e multiplica todos os "multiply" que se aplicam a este Stat.
float add = 0f, multiply = 1f;
foreach (var modifier in _modifiers)
{
if (modifier.stat != id) continue;
add += modifier.add;
multiply *= modifier.multiply;
}
return (value + add) * multiply;
}

// Chegou no teto? (o teto vale para o valor dos Upgrades, antes dos modificadores)
public bool IsMaxed(StatId id)
{
var definition = _definitions[id];
return definition.HasCap && definition.Base + definition.PerLevel * _levels[id] >= definition.Cap;
}

// Custo do próximo nível: baseCost × 1,15^nível, arredondado para o inteiro mais próximo.
public int NextCost(StatId id)
{
return Mathf.RoundToInt(_definitions[id].BaseCost * Mathf.Pow(StatDefinition.CostGrowth, _levels[id]));
}

// Sobe um nível. "internal": só o UpgradeService deve chamar (depois de cobrar os Shards).
internal void IncreaseLevel(StatId id)
{
_levels[id]++;
StatChanged?.Invoke(id);
}

// Registra um modificador do Planet Core e avisa que o Stat mudou.
public void AddModifier(StatModifier modifier)
{
_modifiers.Add(modifier);
StatChanged?.Invoke(modifier.stat);
}
}
}

Passo 3 — Os Shards e a compra: ShardWallet, UpgradeService, SatelliteShop​

// Caminho: Assets/_Project/Scripts/Economy/ShardWallet.cs
using System;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: os Shards chegam em frações (Swarmer vale 0,5), mas o jogador
// vê e gasta Shards inteiros. Alguém precisa acumular as frações sem perder nada.
// ESTRATÉGIA: classe C# pura com o saldo inteiro (Balance) e um "resto" fracionário guardado à parte.
// Avisa por evento (BalanceChanged) para a UI não precisar conferir o saldo a cada frame.
// Saldo de Shards da run. Frações (Swarmer vale 0,5) são acumuladas
// e viram Shards inteiros quando somam 1 (Seção 4.4).
public sealed class ShardWallet
{
private float _fraction; // a parte "quebrada" ainda não convertida em Shard inteiro

public int Balance { get; private set; }

// Todos os Shards ganhos na run, inclusive os já gastos. Mostrado no Results (Fase 8).
public float TotalEarned { get; private set; }

public event Action BalanceChanged;

// Soma Shards (inteiros ou fracionários). Só avisa quando o saldo inteiro muda de fato.
public void Add(float amount)
{
if (amount <= 0f) return;
TotalEarned += amount;
_fraction += amount;
int whole = Mathf.FloorToInt(_fraction + 0.0001f); // a folga evita perder 1 Shard por erro de ponto flutuante
if (whole <= 0) return;
_fraction -= whole;
Balance += whole;
BalanceChanged?.Invoke();
}

// Gasta se houver saldo. Padrão "Try": devolve true/false em vez de dar erro quando não pode.
public bool TrySpend(int amount)
{
if (amount > Balance) return false;
Balance -= amount;
BalanceChanged?.Invoke();
return true;
}
}
}
// Caminho: Assets/_Project/Scripts/Economy/UpgradeService.cs
using System;

namespace Armageddon.Economy
{
// POR QUE: "comprar um Upgrade" envolve duas coisas que precisam andar juntas: cobrar os Shards e
// subir o nível. Se a UI fizesse isso direto, poderia cobrar e não subir (ou o contrário).
// ESTRATÉGIA: classe C# pura que junta RunStats e ShardWallet e oferece só duas perguntas/ações:
// CanPurchase (para a UI acender ou apagar o card) e TryPurchase (a compra em si).
// Compra um nível de Stat com Shards. Nunca pausa o jogo (Pilar 1).
public sealed class UpgradeService
{
private readonly RunStats _stats;
private readonly ShardWallet _wallet;

public event Action<StatId, int> Purchased; // Stat e o nível novo (dicas na Fase 13)

// As dependências chegam pelo construtor (quem cria é o RunEconomy): nada de procurar objetos.
public UpgradeService(RunStats stats, ShardWallet wallet)
{
_stats = stats;
_wallet = wallet;
}

// Pode comprar se não está no teto e há Shards suficientes.
public bool CanPurchase(StatId id) => !_stats.IsMaxed(id) && _wallet.Balance >= _stats.NextCost(id);

// Faz a compra completa ou nada. Devolve se deu certo.
public bool TryPurchase(StatId id)
{
if (!CanPurchase(id)) return false;
_wallet.TrySpend(_stats.NextCost(id)); // o custo é o do nível ATUAL, antes de subir
_stats.IncreaseLevel(id);
Purchased?.Invoke(id, _stats.Level(id));
return true;
}
}
}
// Caminho: Assets/_Project/Scripts/Economy/SatelliteShop.cs
using System;
using System.Collections.Generic;
using Armageddon.Combat;

namespace Armageddon.Economy
{
// POR QUE: os Satellites 2, 3 e 4 são comprados durante a run, com Shards (Seção 4.2). Parecem um
// Upgrade, mas não são um Stat: não têm nível nem fórmula de valor, só "mais um na órbita" a um preço fixo.
// ESTRATÉGIA: classe C# pura, irmã do UpgradeService: junta a carteira e a órbita e oferece as mesmas
// perguntas e ações (NextCost, CanPurchase, TryPurchase). Quem cria é o RunEconomy, que também guarda
// a lista de preços (editável no Inspector). A órbita já sabe adicionar Satellites espaçados (SetCount).
// Compra o próximo Satellite da run. Nunca pausa o jogo (Pilar 1).
public sealed class SatelliteShop
{
private readonly SatelliteOrbit _orbit;
private readonly ShardWallet _wallet;
private readonly IReadOnlyList<int> _costs; // índice 0 = preço do 2º Satellite, 1 = do 3º, 2 = do 4º

public event Action<int> Purchased; // o novo número de Satellites (conquista Full Arsenal na Fase 9)

// As dependências chegam pelo construtor, como no UpgradeService.
public SatelliteShop(SatelliteOrbit orbit, ShardWallet wallet, IReadOnlyList<int> costs)
{
_orbit = orbit;
_wallet = wallet;
_costs = costs;
}

public int Count => _orbit.Satellites.Count;

// Sem mais compras quando a órbita está cheia (ou a lista de preços acabou).
public bool IsMaxed => Count >= SatelliteOrbit.MaxSatellites || Count - 1 >= _costs.Count;

// Com 1 Satellite em órbita, o próximo é o 2º: preço _costs[0].
public int NextCost => IsMaxed ? 0 : _costs[Count - 1];

public bool CanPurchase => !IsMaxed && _wallet.Balance >= NextCost;

// Faz a compra completa ou nada. O preço é lido ANTES de o Satellite entrar (depois, NextCost já é o do seguinte).
public bool TryPurchase()
{
if (!CanPurchase) return false;
_wallet.TrySpend(NextCost);
_orbit.SetCount(Count + 1); // dispara o CountChanged: a aba Satellites ganha a linha nova
Purchased?.Invoke(Count);
return true;
}
}
}

Passo 4 — A economia da run: RunEconomy​

// Caminho: Assets/_Project/Scripts/Economy/RunEconomy.cs
using System.Collections.Generic;
using Armageddon.Combat;
using Armageddon.Enemies;
using Armageddon.Planets;
using Armageddon.Waves;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: RunStats, ShardWallet, UpgradeService e SatelliteShop são classes puras: alguém precisa
// criá-las a cada run e ligá-las ao resto do jogo (inimigos dão Shards, Wave Clear dá Shards, Stats
// mudam Satellites e planeta). Sem um "ligador", esses sistemas teriam que se conhecer.
// ESTRATÉGIA: composição — um MonoBehaviour na cena Gameplay que (1) cria as quatro classes no Awake,
// (2) escuta os eventos do EnemyPool, do WaveDirector e do RunStats, e (3) empurra os valores novos
// para o SatelliteOrbit e o PlanetHealth. A UI lê Stats/Wallet/Upgrades/Satellites pelas propriedades públicas.
// Cria os Stats, a carteira e as lojas da run; transforma kills e Wave Clears em Shards;
// e aplica os valores dos Stats nos Satellites e no planeta sempre que algum muda.
public sealed class RunEconomy : MonoBehaviour
{
[SerializeField] private StatCatalog _catalog;
[SerializeField] private WaveBalance _waveBalance;
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private WaveDirector _waves;
[SerializeField] private int _startingShards = 15; // saldo no início de toda run (Seção 4.2)
[SerializeField] private int[] _satelliteCosts = { 10, 50, 100 }; // 2º, 3º e 4º Satellite (Seção 4.2)

public StatCatalog Catalog => _catalog;
public RunStats Stats { get; private set; }
public ShardWallet Wallet { get; private set; }
public UpgradeService Upgrades { get; private set; }
public SatelliteShop Satellites { get; private set; }

// Cria a economia da run. No Awake porque outros scripts (a gaveta) leem Stats/Wallet no Start deles.
private void Awake()
{
Stats = new RunStats(_catalog.Stats);
Wallet = new ShardWallet();
Upgrades = new UpgradeService(Stats, Wallet);
Satellites = new SatelliteShop(_orbit, Wallet, _satelliteCosts);
}

// Liga os eventos (OnEnable roda depois do Awake, então Stats já existe).
private void OnEnable()
{
Stats.StatChanged += HandleStatChanged;
_enemies.EnemyKilled += HandleEnemyKilled;
_waves.WaveCleared += HandleWaveCleared;
}

private void OnDisable()
{
Stats.StatChanged -= HandleStatChanged;
_enemies.EnemyKilled -= HandleEnemyKilled;
_waves.WaveCleared -= HandleWaveCleared;
}

// Saldo inicial e primeira aplicação dos Stats (substitui os valores provisórios do Inspector das Fases 2 e 3).
private void Start()
{
Wallet.Add(_startingShards);
ApplyAll();
}

// O Planet Core (Fase 9) chama isto no começo da run.
public void AddModifier(StatModifier modifier)
{
Stats.AddModifier(modifier);
}

// Qualquer Stat mudou (compra ou modificador): reaplica todos.
private void HandleStatChanged(StatId id)
{
ApplyAll(); // são só 13 números: recalcular tudo é mais simples e à prova de esquecimento
}

// Copia os valores atuais dos Stats para quem os usa: Satellites (via um SatelliteStats novo) e planeta.
private void ApplyAll()
{
_orbit.SetStats(new SatelliteStats
{
damage = Stats.Value(StatId.Damage),
attackSpeed = Stats.Value(StatId.AttackSpeed),
criticalChance = Stats.Value(StatId.CriticalChance),
criticalFactor = Stats.Value(StatId.CriticalFactor),
attackRange = Stats.Value(StatId.AttackRange),
impetus = Stats.Value(StatId.Impetus),
orbitSpeed = Stats.Value(StatId.OrbitSpeed),
});

_planet.SetMaxHitpoints(Stats.Value(StatId.Hitpoints));
_planet.SetRegeneration(Stats.Value(StatId.Regeneration));
_planet.SetDefense(Stats.Value(StatId.DefenseAbsolute), Stats.Value(StatId.DefenseRelative));
}

// Shards por kill. O Resource Bonus é a chance de a kill pagar o dobro (Seção 4.4).
// UnityEngine.Random.value = número aleatório entre 0 e 1.
private void HandleEnemyKilled(Enemy enemy)
{
float shards = enemy.Definition.Shards;
bool doubled = UnityEngine.Random.value < Stats.Value(StatId.ResourceBonus);
Wallet.Add(doubled ? shards * 2f : shards);
}

// Bônus de Wave Clear por Wave paga: (5 + w) × (1 + Resource per Wave) (Seção 4.3).
private void HandleWaveCleared(IReadOnlyList<int> paidWaves, bool beforeTimer)
{
float multiplier = 1f + Stats.Value(StatId.ResourcePerWave);
foreach (int wave in paidWaves) Wallet.Add(_waveBalance.ClearBonus(wave) * multiplier);
}
}
}

Por que o SetStats recebe um objeto novo? O SatelliteOrbit e o QuadrantView leem o SatelliteStats a cada frame. Trocar o objeto inteiro de uma vez garante que nenhum dos dois veja um valor pela metade. E, como é só numa compra, não há custo.

Passo 5 — A escala da UI: IntegerCanvasScale​

// Caminho: Assets/_Project/Scripts/UI/IntegerCanvasScale.cs
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: o mundo é ampliado por um fator inteiro (Pixel Perfect Camera). Se a UI fosse ampliada por
// um fator quebrado (o "Scale With Screen Size" padrão do CanvasScaler), os pixels dos botões e textos
// ficariam de tamanhos diferentes dos pixels do mundo.
// ESTRATÉGIA: um componente no Canvas que coloca o CanvasScaler em "Constant Pixel Size" e define o
// scaleFactor como o maior inteiro que cabe (largura ÷ 320 e altura ÷ 180, o menor dos dois).
// Recalcula quando a janela muda de tamanho.
// Amplia a UI pelo mesmo fator INTEIRO da Pixel Perfect Camera (6× em 1080p): 1 unidade de UI = 1 pixel do jogo.
// Os tamanhos da UI neste guia são em pixels da tela de referência de 320×180.
[RequireComponent(typeof(CanvasScaler))]
public sealed class IntegerCanvasScale : MonoBehaviour
{
[SerializeField] private int _referenceWidth = 320;
[SerializeField] private int _referenceHeight = 180;

private CanvasScaler _scaler;
private int _lastWidth; // tamanho da tela na última aplicação, para detectar mudança
private int _lastHeight;

// Força o modo de escala certo (por código, para não depender do Inspector) e aplica já.
private void Awake()
{
_scaler = GetComponent<CanvasScaler>();
_scaler.uiScaleMode = CanvasScaler.ScaleMode.ConstantPixelSize;
Apply();
}

// Conferir dois inteiros por frame é barato; evita depender de um evento de "tela mudou".
private void Update()
{
if (Screen.width != _lastWidth || Screen.height != _lastHeight) Apply(); // janela redimensionada (Web)
}

// Divisão entre inteiros no C# já descarta a parte decimal (1920 / 320 = 6). Mínimo de 1×.
private void Apply()
{
_lastWidth = Screen.width;
_lastHeight = Screen.height;
_scaler.scaleFactor = Mathf.Max(1, Mathf.Min(_lastWidth / _referenceWidth, _lastHeight / _referenceHeight));
}
}
}

Passo 6 — Os cards e a gaveta: StatCard, SatellitePanel, UpgradeDrawer​

// Caminho: Assets/_Project/Scripts/UI/StatCard.cs
using Armageddon.Economy;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: cada Stat aparece na gaveta como um card clicável que mostra o nível, o ganho do próximo
// Upgrade e o custo, e que se apaga quando não dá para comprar.
// ESTRATÉGIA: um prefab de card reaproveitado para qualquer Stat: Bind(stat, economia) diz "agora você
// mostra este Stat". O card se inscreve nos eventos StatChanged e BalanceChanged e só se redesenha
// quando eles disparam (UI por eventos, sem conferir nada a cada frame).
// Um card da gaveta (Seção 6.3): ícone, nome, nível, valor atual > próximo valor, e custo. Tocar compra.
public sealed class StatCard : MonoBehaviour
{
// Referências aos filhos do prefab, arrastadas no Inspector. TMP_Text = texto do TextMeshPro.
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _name;
[SerializeField] private TMP_Text _level;
[SerializeField] private TMP_Text _value;
[SerializeField] private TMP_Text _cost;
[SerializeField] private Button _button;

private RunEconomy _economy;
private StatDefinition _stat;

// Liga o clique do botão ao método de compra. onClick.AddListener = "quando clicar, chame isto" (por código,
// em vez de configurar o OnClick no Inspector).
private void Awake()
{
_button.onClick.AddListener(HandleClick);
}

// Ao destruir o card, desfaz as inscrições nos eventos.
private void OnDestroy()
{
Unbind();
}

// Associa o card a um Stat. Chama Unbind antes, porque o mesmo card é reaproveitado ao trocar de aba.
public void Bind(StatDefinition stat, RunEconomy economy)
{
Unbind();
_stat = stat;
_economy = economy;
_icon.sprite = stat.Icon;
_name.text = stat.DisplayName;
_economy.Stats.StatChanged += HandleStatChanged;
_economy.Wallet.BalanceChanged += Refresh;
Refresh();
}

// Desfaz as inscrições do Bind anterior (sem isso, um card reaproveitado ouviria dois Stats).
private void Unbind()
{
if (_economy == null) return;
_economy.Stats.StatChanged -= HandleStatChanged;
_economy.Wallet.BalanceChanged -= Refresh;
_economy = null;
}

// Toque no card: tenta comprar. Se der certo, os eventos disparam e o Refresh atualiza o card sozinho.
private void HandleClick()
{
_economy.Upgrades.TryPurchase(_stat.Id);
}

// StatChanged avisa qualquer Stat; o card só se redesenha se for o seu.
private void HandleStatChanged(StatId id)
{
if (id == _stat.Id) Refresh();
}

// Redesenha textos e liga/desliga o botão conforme o estado atual.
private void Refresh()
{
var stats = _economy.Stats;
int level = stats.Level(_stat.Id);
float now = stats.Value(_stat.Id);
_level.text = $"Lv {level}";

if (stats.IsMaxed(_stat.Id))
{
_value.text = _stat.Format(now);
_cost.text = "MAX";
_button.interactable = false;
return;
}

// A fonte não tem "→" (Seção 6.3): o card usa ">".
_value.text = $"{_stat.Format(now)} > {_stat.Format(stats.ValueAtLevel(_stat.Id, level + 1))}";
_cost.text = stats.NextCost(_stat.Id).ToString();
// interactable = false deixa o botão cinza e ignora toques.
_button.interactable = _economy.Upgrades.CanPurchase(_stat.Id);
}
}
}
// Caminho: Assets/_Project/Scripts/UI/SatellitePanel.cs
using System.Collections.Generic;
using Armageddon.Combat;
using Armageddon.Economy;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: o jogador escolhe a Target Priority de cada Satellite (as 4 estão sempre disponíveis) e
// compra novos Satellites durante a run (Seção 4.2).
// ESTRATÉGIA: uma linha (botão) por Satellite, criada sob demanda a partir de um prefab e reaproveitada
// (linhas sobrando ficam escondidas). Tocar na linha avança para a próxima prioridade.
// Depois das linhas, um botão fixo compra o próximo Satellite pela SatelliteShop do RunEconomy.
// Redesenha quando o número de Satellites muda (CountChanged) e o botão quando o saldo muda (BalanceChanged).
// Aba Satellites (Seção 6.3): uma linha por Satellite; tocar troca a Target Priority.
public sealed class SatellitePanel : MonoBehaviour
{
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private RunEconomy _economy;
[SerializeField] private Button _rowPrefab;
[SerializeField] private Transform _rows; // o container (com Layout Group) onde as linhas são criadas
[SerializeField] private Button _buyButton; // "comprar Satellite", filho do mesmo container
[SerializeField] private TMP_Text _buyText;

// As 4 Target Priorities, na ordem em que o toque alterna (Seção 4.1). "static readonly": uma lista
// fixa, compartilhada por todas as instâncias e criada uma vez só.
private static readonly TargetPriority[] Priorities =
{
TargetPriority.Closest, TargetPriority.Weakest, TargetPriority.Strongest, TargetPriority.Farthest,
};

private readonly List<Button> _buttons = new List<Button>();

// O clique do botão de compra é ligado uma vez só.
private void Awake()
{
_buyButton.onClick.AddListener(() => _economy.Satellites.TryPurchase());
}

// A aba só fica ativa quando está aberta: ouvir e redesenhar só enquanto visível.
// A carteira nasce no Awake do RunEconomy, que pode rodar depois deste OnEnable no carregamento
// da cena; por isso o "if". Na primeira vez que a aba é aberta de fato, ela já existe.
private void OnEnable()
{
_orbit.CountChanged += Refresh;
if (_economy.Wallet != null) _economy.Wallet.BalanceChanged += RefreshBuyButton;
Refresh();
}

// Desinscrever um método que não estava inscrito é inofensivo em C#.
private void OnDisable()
{
_orbit.CountChanged -= Refresh;
if (_economy != null && _economy.Wallet != null) _economy.Wallet.BalanceChanged -= RefreshBuyButton;
}

// Garante uma linha por Satellite e atualiza os textos.
public void Refresh()
{
var satellites = _orbit.Satellites;
while (_buttons.Count < satellites.Count)
{
// "index" é copiado numa variável local de propósito: a lambda abaixo "captura" esse valor.
// Se usássemos _buttons.Count direto dentro da lambda, todas as linhas leriam o valor final.
int index = _buttons.Count;
var button = Instantiate(_rowPrefab, _rows);
button.onClick.AddListener(() => Cycle(index));
_buttons.Add(button);
}

for (int i = 0; i < _buttons.Count; i++)
{
bool visible = i < satellites.Count;
_buttons[i].gameObject.SetActive(visible);
if (visible) _buttons[i].GetComponentInChildren<TMP_Text>().text = $"Satellite {i + 1}: {satellites[i].Priority}";
}

_buyButton.transform.SetAsLastSibling(); // linhas novas entram no fim do container; o botão volta para depois delas
RefreshBuyButton();
}

// Mostra o preço do próximo Satellite; some com a órbita cheia; fica desabilitado sem Shards.
private void RefreshBuyButton()
{
var shop = _economy.Satellites;
if (shop == null) return; // a economia ainda não foi criada (ver OnEnable)
_buyButton.gameObject.SetActive(!shop.IsMaxed);
if (shop.IsMaxed) return;
_buyButton.interactable = shop.CanPurchase;
_buyText.text = $"+ Satellite {shop.Count + 1}: {shop.NextCost} Shards";
}

// Avança o Satellite "index" para a próxima prioridade, voltando à primeira depois da última.
// O operador % ("resto da divisão") faz a volta: depois do índice 3 vem o 0.
private void Cycle(int index)
{
var satellite = _orbit.Satellites[index];
int current = System.Array.IndexOf(Priorities, satellite.Priority);
satellite.Priority = Priorities[(current + 1) % Priorities.Length];
Refresh();
}
}
}
// Caminho: Assets/_Project/Scripts/UI/UpgradeDrawer.cs
using System.Collections.Generic;
using Armageddon.Economy;
using Armageddon.World;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: a gaveta é a interface principal do jogo: mostra o saldo e deixa comprar Upgrades sem pausar.
// Ela coordena várias peças (abas, cards, painel de Satellites, câmera).
// ESTRATÉGIA: um controlador de tela com um único estado (OpenTab). SetOpen(aba) é o único método que
// liga/desliga as peças; os botões só chamam SetOpen. Os cards são criados sob demanda e reaproveitados
// entre as abas (Bind troca o Stat de cada card). O saldo é atualizado pelo evento BalanceChanged.
// A gaveta de upgrades (Seção 6.3). Fechada: faixa com o saldo e as 4 abas.
// Aberta: os cards da Track tocada (ou a aba Satellites), e a câmera desliza. Nunca pausa o jogo.
public sealed class UpgradeDrawer : MonoBehaviour
{
// Os números 0–2 batem com StatTrack (Offense, Defense, Utility), o que permite converter um no outro.
public enum Tab { None = -1, Offense = 0, Defense = 1, Utility = 2, Satellites = 3 }

[SerializeField] private RunEconomy _economy;
[SerializeField] private CameraRig _camera;
[SerializeField] private Button[] _tabButtons = new Button[4]; // na ordem Offense, Defense, Utility, Satellites
[SerializeField] private TMP_Text _shardsText;
[SerializeField] private GameObject _panel; // a parte que abre
[SerializeField] private GameObject _cardsView; // a rolagem com os cards
[SerializeField] private Transform _cardContainer;
[SerializeField] private StatCard _cardPrefab;
[SerializeField] private SatellitePanel _satellitePanel;

private readonly List<StatCard> _cards = new List<StatCard>();

public Tab OpenTab { get; private set; } = Tab.None;

// Liga cada botão de aba à sua aba. A variável "tab" local é capturada pela lambda (ver SatellitePanel.Refresh).
private void Awake()
{
for (int i = 0; i < _tabButtons.Length; i++)
{
var tab = (Tab)i;
_tabButtons[i].onClick.AddListener(() => HandleTabClicked(tab));
}
}

// Start, e não Awake/OnEnable: a carteira é criada no Awake do RunEconomy, e a câmera no Awake do CameraRig.
// (A Unity não garante a ordem dos Awake entre objetos diferentes; no Start, todos os Awake já rodaram.)
private void Start()
{
_economy.Wallet.BalanceChanged += RefreshShards;
RefreshShards();
SetOpen(Tab.None);
}

// Desinscreve do saldo. Confere null porque, ao fechar a cena, o RunEconomy pode já ter sido destruído.
private void OnDestroy()
{
if (_economy != null && _economy.Wallet != null) _economy.Wallet.BalanceChanged -= RefreshShards;
}

// Clique numa aba: abre essa aba, ou fecha se ela já estava aberta.
private void HandleTabClicked(Tab tab)
{
SetOpen(OpenTab == tab ? Tab.None : tab); // tocar na aba aberta fecha a gaveta
}

// Único lugar que muda o que a gaveta mostra. Tab.None = fechada.
public void SetOpen(Tab tab)
{
OpenTab = tab;
bool open = tab != Tab.None;
bool satellites = tab == Tab.Satellites;

_panel.SetActive(open);
_camera.SetDrawerOpen(open);
_cardsView.SetActive(open && !satellites);
_satellitePanel.gameObject.SetActive(satellites);

if (open && !satellites) ShowTrack((StatTrack)(int)tab);
}

// Mostra os cards dos Stats de uma Track, criando cards novos só se faltarem.
private void ShowTrack(StatTrack track)
{
var stats = _economy.Catalog.ForTrack(track);
while (_cards.Count < stats.Count) _cards.Add(Instantiate(_cardPrefab, _cardContainer));

for (int i = 0; i < _cards.Count; i++)
{
bool visible = i < stats.Count;
_cards[i].gameObject.SetActive(visible);
if (visible) _cards[i].Bind(stats[i], _economy);
}
}

// Atualiza o texto do saldo (chamado pelo evento BalanceChanged).
private void RefreshShards()
{
_shardsText.text = _economy.Wallet.Balance.ToString();
}
}
}

Passo 7 — Teclas de teste: EconomyDevTools​

// Caminho: Assets/_Project/Scripts/Economy/EconomyDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Economy
{
// POR QUE: testar todos os Upgrades e os Satellites exige muitos Shards.
// ESTRATÉGIA: componente de cena (mesma regra do CombatDevTools) que dá Shards e escreve cada compra
// no Console (ouvindo o evento Purchased).
// M = +100 Shards. Registra as compras no Console.
public sealed class EconomyDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private RunEconomy _economy;

// Start: o UpgradeService só existe depois do Awake do RunEconomy.
private void Start()
{
_economy.Upgrades.Purchased += HandlePurchased;
}

// Desinscreve (conferindo null, como no UpgradeDrawer.OnDestroy).
private void OnDestroy()
{
if (_economy != null && _economy.Upgrades != null) _economy.Upgrades.Purchased -= HandlePurchased;
}

// Lê as teclas de teste.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.mKey.wasPressedThisFrame) _economy.Wallet.Add(100f);
}

// Escreve cada compra no Console.
private void HandlePurchased(StatId id, int level)
{
var definition = _economy.Stats.Definition(id);
Debug.Log($"[Economy] {id} nível {level}: {definition.Format(_economy.Stats.Value(id))}. Saldo: {_economy.Wallet.Balance}");
}
#endif
}
}

Em Assets/_Project/Data/Stats/, Create → Armageddon → Stat Definition, uma vez para cada linha (valores da Seção 4.2; percentuais como fração). O Icon é a célula de Art/UI/SPR_UI_Icons indicada (Seção 6.4).

AssetIdTrackDisplay NameBasePer LevelCapBase CostFormatIcon (célula)
Stat_DamageDamageOffenseDamage10305Number0
Stat_AttackSpeedAttackSpeedOffenseAttack Speed10.0866PerSecond1
Stat_CriticalChanceCriticalChanceOffenseCrit Chance00.0150.88Percent2
Stat_CriticalFactorCriticalFactorOffenseCrit Factor1.50.168Multiplier3
Stat_AttackRangeAttackRangeOffenseRange40.1586Units4
Stat_ImpetusImpetusOffenseImpetus00.0040.1210PercentPerUnit5
Stat_OrbitSpeedOrbitSpeedOffenseOrbit Speed4551806DegreesPerSecond6
Stat_HitpointsHitpointsDefenseHitpoints1002505Number7
Stat_RegenerationRegenerationDefenseRegen00.408PerSecond8
Stat_DefenseAbsoluteDefenseAbsoluteDefenseDefense00.508Number9
Stat_DefenseRelativeDefenseRelativeDefenseDefense %00.0150.7510Percent10
Stat_ResourceBonusResourceBonusUtilityDouble Shards00.0516Percent11
Stat_ResourcePerWaveResourcePerWaveUtilityWave Bonus00.106Percent12

Depois, Create → Armageddon → Stat Catalog em Data/Stats/, com o nome StatCatalog, e arraste os 13 assets nesta ordem para a lista Stats: é a ordem dos cards na gaveta.

Passo 9 — Montar a economia na cena​

Em [Systems] da cena Gameplay, crie RunEconomy com o componente RunEconomy: Catalog = StatCatalog; Wave Balance = WaveBalance; Orbit = o SatelliteOrbit; Planet = o PlanetHealth do objeto Planet; Enemies = o EnemyPool; Waves = o WaveDirector; Starting Shards = 5; Satellite Costs = 10, 50, 100 (já vem preenchido; Seção 4.2).

A partir de agora, os Stats do SatelliteOrbit e do PlanetHealth no Inspector são sobrescritos pelo RunEconomy quando o Play começa. Para testar valores, compre Upgrades (ou use M para ganhar Shards).

Passo 10 — Montar a gaveta​

Todos os tamanhos e posições abaixo estão em pixels da tela de referência de 320×180.

  1. Canvas: em [UI] da cena Gameplay, UI → Canvas. Nome HUDCanvas. Render Mode = Screen Space - Overlay; Pixel Perfect marcado. No Canvas Scaler, adicione o componente Integer Canvas Scale (ele troca o modo para Constant Pixel Size e calcula o fator sozinho). Se não houver um EventSystem na cena, crie com UI → Event System e, no Inspector dele, clique em Replace with InputSystemUIInputModule.

  2. Gaveta: dentro do HUDCanvas, crie um objeto de UI vazio UpgradeDrawer, ancorado embaixo e esticado na horizontal (preset bottom stretch), com Height 72 (40% de 180) e Pos Y 0. Adicione o componente UpgradeDrawer.

  3. Faixa (Strip): dentro de UpgradeDrawer, uma UI → Image Strip, ancorada embaixo (bottom stretch), Height 20. Source Image = SPR_UI_DrawerStrip; Image Type = Tiled.

    • Dentro de Strip, 4 UI → Button (TabOffense, TabDefense, TabUtility, TabSatellites) de 24 × 18, lado a lado a partir da esquerda (X = 2, 28, 54, 80; Y centralizado). Cada botão: Image com SPR_UI_Button (a célula normal), Image Type Sliced, Transition Sprite Swap com a célula "pressionado" em Pressed Sprite e "desabilitado" em Disabled Sprite. Apague o texto do botão e crie dentro uma UI → Image de 16 × 16 com o ícone da aba: células 13, 14, 15 e 16 de SPR_UI_Icons.
    • À direita da faixa: uma UI → Image 16 × 16 com o ícone de Shards (célula 17) e, ao lado, um UI → Text - TextMeshPro ShardsText com a fonte m5x7 Raster, tamanho 16, alinhado à esquerda.
  4. Painel (a parte que abre): dentro de UpgradeDrawer, uma UI → Image Panel, ancorada em cima (top stretch), Height 52. Source Image = SPR_UI_Panel; Image Type = Sliced.

    • Dentro de Panel, uma UI → Scroll View CardsView que preenche o painel (stretch-stretch, margens de 4). Na Scroll Rect: Horizontal marcado, Vertical desmarcado; apague as barras de rolagem. No objeto Content, adicione Horizontal Layout Group (Spacing 4, Child Force Expand desmarcado) e Content Size Fitter (Horizontal Fit = Preferred Size).
    • Também dentro de Panel, um objeto de UI vazio SatellitePanel (stretch-stretch, margens de 4) com Grid Layout Group (Cell Size 140 × 12, Spacing 2 × 2, Constraint = Fixed Column Count, Constraint Count 2) e o componente SatellitePanel: Orbit = o SatelliteOrbit; Economy = o RunEconomy; Rows = o próprio SatellitePanel. Em 2 colunas, as 4 linhas e o botão de compra ocupam no máximo 3 fileiras de 12 px, que cabem nos 44 px do painel.
    • Dentro de SatellitePanel, uma UI → Button BuySatellite com Image SPR_UI_Button (Sliced, Sprite Swap como as abas) e um TMP (m5x7, 16) dentro. O Grid define o tamanho. No SatellitePanel, Buy Button = BuySatellite e Buy Text = o TMP dele.
  5. Prefab do card (StatCard): uma UI → Button de 60 × 44 com Image SPR_UI_Button (Sliced, Sprite Swap como as abas) e Layout Element (Preferred Width 60, Preferred Height 44). Apague o texto padrão e crie dentro:

    • Icon: UI → Image 16 × 16 no canto de cima à esquerda (X 4, Y −4).
    • Name: TMP (m5x7, 16) à direita do ícone, em cima.
    • Level: TMP logo abaixo do nome.
    • Value: TMP numa linha inteira no meio.
    • Cost: TMP embaixo, com um ícone de Shards (célula 17) ao lado.

    Adicione o componente StatCard e ligue os campos. Arraste para Prefabs/UI/ e apague da cena.

  6. Prefab da linha de Satellite: uma UI → Button de 140 × 12 com Image SPR_UI_Button (Sliced) e um TMP (m5x7, 16) dentro, e Layout Element (Preferred Height 12). Arraste para Prefabs/UI/ como SatelliteRow e apague da cena. No SatellitePanel, Row Prefab = SatelliteRow.

  7. Ligue o UpgradeDrawer: Economy = RunEconomy; Camera = o CameraRig da Main Camera; Tab Buttons = os 4 botões, na ordem Offense, Defense, Utility, Satellites; Shards Text = ShardsText; Panel = Panel; Cards View = CardsView; Card Container = o Content da Scroll View; Card Prefab = StatCard; Satellite Panel = SatellitePanel.

  8. Em [Systems], crie EconomyDevTools com o componente EconomyDevTools: Economy = RunEconomy.

A tecla F3 do DevShortcuts (Fase 2) continua movendo a câmera sozinha, sem abrir a gaveta. Agora prefira as abas.

Passo 11 — Commit​

git add . e git commit -m "Phase 6: stats, shards, upgrades, upgrade drawer".

✅ Checkpoint:

  • Play: a faixa da gaveta aparece embaixo com as 4 abas e o saldo 15. Cada Grunt destruído soma 1; um Wave Clear da Wave 1 soma 6 (5 + 1).
  • Dois Swarmers destruídos somam 1 Shard (0,5 cada, acumulado).
  • Tocar em Offense abre a gaveta com 7 cards (Damage, Attack Speed…), e o mundo desliza para o planeta ficar no centro da área livre. Tocar de novo em Offense fecha a gaveta.
  • O card de Damage mostra Lv 0, 10 > 13 e custo 5. Cards que o saldo não paga ficam desabilitados e se habilitam sozinhos quando o saldo chega ao custo.
  • Comprar Damage: o saldo cai 5, o card vira Lv 1, 13 > 16, custo 6 (5 × 1,15 = 5,75, arredondado), e o Grunt passa a morrer igual, mas o Brute precisa de menos tiros.
  • Comprar Range faz o preenchimento do Quadrant crescer na hora. Orbit Speed faz o Satellite girar mais rápido.
  • Comprar Hitpoints com o planeta ferido (F6 algumas vezes): o máximo e o HP atual sobem 25.
  • Crit Chance chega a MAX quando atinge 80% e deixa de ser comprável.
  • Aba Satellites: uma linha Satellite 1: Closest e o botão + Satellite 2: 10 Shards, já habilitado (o saldo inicial de 15 paga), e desabilitado quando o saldo fica abaixo de 10. Tocando na linha, ela alterna Closest → Weakest → Strongest → Farthest → Closest.
  • Com M, compre o Satellite 2: o saldo cai 10, o segundo Satellite aparece do lado oposto da órbita, surge a linha Satellite 2: Closest e o botão passa a + Satellite 3: 50 Shards. Depois do 4º, o botão some. As teclas 2–4 da Fase 3 também mudam o número de Satellites (sem cobrar), e o botão acompanha.
  • O jogo nunca pausa ao abrir a gaveta ou comprar.
  • Em 1920x1080, a UI tem o mesmo tamanho de pixel que o mundo (6×). Em 1280x720, os dois ficam em 4×.

Problemas comuns:

  • Tocar nas abas não faz nada: falta o EventSystem na cena, ou ele usa o módulo antigo (Standalone Input Module). Troque por InputSystemUIInputModule.
  • KeyNotFoundException no RunStats: o StatCatalog não tem os 13 Stats, ou dois assets estão com o mesmo Id.
  • Os cards aparecem uns em cima dos outros: falta o Horizontal Layout Group no Content, ou o Layout Element no prefab do card.
  • A UI fica gigante ou minúscula: o Canvas Scaler ainda está em Scale With Screen Size. O IntegerCanvasScale troca o modo no Awake; confira se ele está no mesmo objeto que o Canvas Scaler.
  • A UI fica borrada: o Canvas está sem Pixel Perfect, ou algum texto não usa o Font Asset m5x7 Raster.
  • Comprar não muda nada no jogo: o RunEconomy está sem o Orbit ou o Planet preenchidos, e o Console mostra um NullReferenceException.
  • O botão de comprar Satellite fica sempre desabilitado ou sem texto: o Economy do SatellitePanel está vazio, ou o Buy Text aponta para outro texto.
  • O valor aparece com vírgula ou com ponto dependendo do computador: é a cultura do sistema operacional. A Fase 11 (Localization) fixa a formatação por idioma.

Próxima fase: Fase 7 — Mothership. Ela coloca o boss das Waves múltiplas de 10: órbita, laser telegrafado, lançamentos de naves (Grunts e Scouts, depois Swarmers) e a barra de HP.


Fase 7 — Mothership​

Objetivo desta fase: toda Wave múltipla de 10 traz a Mothership (Seção 4.5). Ela entra pelo setor do aviso, desce em espiral em volta do planeta, telegrafa e dispara o laser a cada 8 s, solta naves a cada 15 s (Grunts e Scouts primeiro, depois cachos de Swarmers), tem uma barra de HP no topo da tela e, ao morrer, explode e paga 50 Shards. Se o timer da Boss Wave acabar com ela viva, ela continua em campo junto com as Waves seguintes.

Conceitos novos:

  • Movimento em duas etapas: primeiro a entrada (linha reta da borda até 7 u) e depois a espiral (o ângulo cresce 10°/s e o raio diminui 1,5 u a cada 360°). A espiral é só "órbita com raio encolhendo": raio -= 1,5 × (10 / 360) × dt, limitado a 3,5 u.
  • Ataque telegrafado: o laser tem duas fases visíveis: o aviso (a linha tracejada piscando por 1,5 s, que dá tempo de o jogador entender o perigo) e o disparo (o feixe, que causa o dano de uma vez). O dano passa pela fórmula de defesa do planeta (Seção 4.2); a Defense relativa é a melhor contra ele.
  • SpriteRenderer em modo Tiled: em vez de criar vários tiles para o laser, um único SpriteRenderer no modo Tiled repete o tile de 32 px ao longo do comprimento que você mandar (size). A linha acompanha a Mothership a cada frame só mudando o comprimento e o ângulo.
  • Inimigo "externo" às Waves: a Mothership não vem do EnemyPool, mas conta como inimiga viva: o MothershipSpawner avisa o WaveDirector (AddExternalAlive), e é por isso que a tela nunca "esvazia" com ela viva (Seção 4.3).

Passo 1 — O laser: LaserAttack​

// Caminho: Assets/_Project/Scripts/Enemies/LaserAttack.cs
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: o laser é o ataque que o jogador precisa VER chegando (telegrafado). Ele tem sua própria
// sequência no tempo (aviso → dano → feixe → some), que não tem nada a ver com o movimento da Mothership.
// ESTRATÉGIA: um componente separado no mesmo objeto da Mothership, com uma pequena máquina de estados
// (Idle → Telegraph → Beam → Idle, ver WaveDirector). A Mothership só chama Begin(dano) e pergunta
// IsBusy; o LaserAttack cuida do visual (linha em modo Tiled + impacto) e aplica o dano no planeta.
// O laser da Mothership (Seção 4.5): aviso tracejado piscando por 1,5 s, depois o feixe, que causa o dano uma vez.
// Fica no mesmo objeto da Mothership e cria a linha e o impacto como objetos filhos.
public sealed class LaserAttack : MonoBehaviour
{
private enum State { Idle, Telegraph, Beam }

[SerializeField] private Sprite[] _warningFrames; // SPR_VFX_Laser_Warning (2)
[SerializeField] private Sprite[] _beamFrames; // SPR_VFX_Laser_Beam (4)
[SerializeField] private Sprite[] _impactFrames; // SPR_VFX_Laser_Impact (4)
[SerializeField] private float _warningFps = 6f;
[SerializeField] private float _beamFps = 12f;
[SerializeField] private float _telegraphTime = 1.5f;
[SerializeField] private float _beamTime = 0.4f; // quanto tempo o feixe fica visível depois do dano
[SerializeField] private string _sortingLayer = "VFX";

private SpriteRenderer _line;
private SpriteAnimator _lineAnimator;
private SpriteAnimator _impact; // a "faísca" onde o laser toca o planeta
private PlanetHealth _planet;
private State _state;
private float _damage;
private float _time; // segundos desde o começo do estado atual

// A Mothership não começa um laser novo enquanto este não terminou.
public bool IsBusy => _state != State.Idle;

// Cria a linha e o impacto (escondidos) uma vez só.
private void Awake()
{
// "out _lineAnimator": o método preenche essa variável além de devolver o SpriteRenderer.
_line = CreatePart("LaserLine", out _lineAnimator);
_line.drawMode = SpriteDrawMode.Tiled; // repete o tile ao longo do comprimento (precisa de Mesh Type = Full Rect)
CreatePart("LaserImpact", out _impact);
Hide();
}

// Recebe o planeta alvo. Chamado pela Mothership no Spawn (o prefab não tem como arrastar o planeta da cena).
public void Init(PlanetHealth planet)
{
_planet = planet;
}

// Começa um disparo: entra no estado de aviso e mostra a linha tracejada.
public void Begin(float damage)
{
_damage = damage;
_state = State.Telegraph;
_time = 0f;
_line.gameObject.SetActive(true);
_lineAnimator.SetFrames(_warningFrames, _warningFps, true);
Place();
}

// Interrompe e esconde (fim natural do feixe, ou a Mothership morreu no meio do aviso).
public void Cancel()
{
_state = State.Idle;
Hide();
}

// Avança a máquina de estados. LateUpdate para posicionar a linha depois de a Mothership se mover neste frame.
private void LateUpdate()
{
if (_state == State.Idle) return;

_time += Time.deltaTime;
if (_state == State.Telegraph && _time >= _telegraphTime)
{
// Fim do aviso: aplica o dano UMA vez e troca o visual para o feixe.
_planet.TakeDamage(_damage);
_state = State.Beam;
_time = 0f;
_lineAnimator.SetFrames(_beamFrames, _beamFps, true);
_impact.gameObject.SetActive(true);
_impact.SetFrames(_impactFrames, _beamFps, true);
}
else if (_state == State.Beam && _time >= _beamTime)
{
Cancel();
return;
}

Place();
}

// Da Mothership até a borda do planeta, NA TELA. A linha é girada: é a exceção de rotação da Seção 6.2.
// Estratégia: as duas pontas são projetadas (a Mothership e o planeta estão no plano de voo); o centro da
// linha fica no meio do caminho; ela é girada para apontar ao planeta e esticada (size) até o comprimento
// certo — o modo Tiled repete o desenho em vez de esticá-lo.
// O dano não depende deste desenho: ele vai direto para o PlanetHealth no fim do aviso.
private void Place()
{
Vector2 from = IsoProjection.GroundToView(transform.position);
Vector2 center = IsoProjection.GroundToView(WorldLayout.PlanetCenter);
Vector2 direction = (center - from).normalized;
// O sprite do planeta é uma esfera de 32 px (1 u de raio na vista também): o feixe para na borda dele.
Vector2 end = center - direction * WorldLayout.PlanetRadius;
float length = Vector2.Distance(from, end);

_line.transform.position = (from + end) * 0.5f;
_line.transform.rotation = Quaternion.Euler(0f, 0f, Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg);
_line.size = new Vector2(length, 2f); // 2 u = a altura da célula de 32 px
_impact.transform.position = end;
}

// Cria um filho com SpriteRenderer + SpriteAnimator. Devolve os dois (um pelo return, outro pelo out).
private SpriteRenderer CreatePart(string name, out SpriteAnimator animator)
{
var go = new GameObject(name);
go.transform.SetParent(transform, false);
var renderer = go.AddComponent<SpriteRenderer>();
renderer.sortingLayerName = _sortingLayer;
animator = go.AddComponent<SpriteAnimator>();
return renderer;
}

// Esconde a linha e o impacto.
private void Hide()
{
_line.gameObject.SetActive(false);
_impact.gameObject.SetActive(false);
}
}
}

Passo 2 — O boss: Mothership​

// Caminho: Assets/_Project/Scripts/Enemies/Mothership.cs
using System;
using Armageddon.Combat;
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: o boss se comporta de um jeito bem diferente dos inimigos comuns: não é kamikaze, entra e
// fica orbitando em espiral, ataca à distância (laser) e lança naves. Forçar isso no Enemy o
// encheria de "if boss".
// ESTRATÉGIA: uma classe própria que também implementa ITarget (os Satellites atiram nela sem código
// novo). O movimento usa coordenadas polares (ângulo + distância ao centro), que deixam a espiral
// trivial. O laser fica num componente à parte (LaserAttack). Não é pooled: aparece uma vez a cada
// 10 Waves, então Instantiate/Destroy (feitos pelo MothershipSpawner) não pesam.
// O boss das Boss Waves (Seção 4.5). Entra em linha reta até 7 u, desce em espiral até 3,5 u,
// e ataca com o laser e lançando naves (Grunts e Scouts, depois Swarmers). Implementa ITarget: os Satellites atiram nela sem código novo.
// Como o Enemy, este objeto é a raiz no chão; o desenho fica no filho View (IsoView). O LaserAttack fica aqui.
[RequireComponent(typeof(LaserAttack))]
public sealed class Mothership : MonoBehaviour, ITarget
{
[Header("Valores base, antes da escalada por Wave (Seção 4.5)")]
[SerializeField] private float _baseHitpoints = 1200f;
[SerializeField] private float _baseLaserDamage = 25f;
[SerializeField] private float _shards = 50f;

[Header("Movimento")]
[SerializeField] private float _entrySpeed = 1.5f; // u/s, da borda até a primeira órbita
[SerializeField] private float _startOrbitRadius = 7f;
[SerializeField] private float _minOrbitRadius = 3.5f;
[SerializeField] private float _shrinkPerLap = 1.5f; // u de raio perdidos a cada volta
[SerializeField] private float _angularSpeed = 10f; // graus por segundo
[SerializeField] private float _radius = 1.5f; // metade do sprite: o alcance mede até a borda

[Header("Ataques")]
[SerializeField] private float _laserInterval = 8f;
[SerializeField] private float _launchInterval = 15f;
// POR QUE: se ela soltasse Swarmers (0,5 Shard cada) desde o início, o jogador quase não ganharia
// Shards para se fortalecer durante a luta. Os primeiros lançamentos são naves simples, que pagam mais.
// ESTRATÉGIA: uma lista de abertura no Inspector (Grunt, Grunt, Scout, Scout); quando ela acaba,
// todo lançamento é um cacho de Swarmers.
[SerializeField] private EnemyDefinition[] _openingLaunches; // um tipo por lançamento de abertura
[SerializeField] private int _openingCount = 4; // naves por lançamento de abertura
[SerializeField] private float _openingSpread = 0.7f; // u: espalha as naves em volta dela
[SerializeField] private EnemyDefinition _swarmer;

private LaserAttack _laser;
private EnemyPool _enemies; // para soltar as naves pelo mesmo caminho dos inimigos comuns
private float _hitpointsMultiplier; // guardados para escalar as naves que ela solta
private float _damageMultiplier;
private float _laserDamage;
private float _angle; // graus, 0 = direita, anti-horário (como a órbita dos Satellites)
private float _distance; // distância atual ao centro
private bool _inOrbit; // false = ainda na entrada em linha reta
private bool _alive;
private float _laserTimer;
private float _launchTimer;
private int _launches; // quantos lançamentos já fez (escolhe abertura ou Swarmers)

public Vector2 Position { get; private set; }
public float Radius => _radius;
public float CurrentHitpoints { get; private set; }
public float MaxHitpoints { get; private set; } // usado pela BossHealthBar
public bool IsAlive => _alive;
public float Shards => _shards;
public int Wave { get; private set; }

public event Action<Mothership> Died;

// Cache do LaserAttack (garantido pelo RequireComponent).
private void Awake()
{
_laser = GetComponent<LaserAttack>();
}

// "Construtor" chamado pelo MothershipSpawner logo depois do Instantiate: posição inicial, vida
// e dano já escalados pela Wave, e as referências da cena que o prefab não tem.
public void Spawn(int wave, float angleDegrees, float hitpointsMultiplier, float damageMultiplier,
PlanetHealth planet, EnemyPool enemies)
{
Wave = wave;
_angle = angleDegrees;
_distance = WorldLayout.SpawnRadius;
_hitpointsMultiplier = hitpointsMultiplier;
_damageMultiplier = damageMultiplier;
_enemies = enemies;
MaxHitpoints = _baseHitpoints * hitpointsMultiplier;
CurrentHitpoints = MaxHitpoints;
_laserDamage = _baseLaserDamage * damageMultiplier;
_inOrbit = false;
_alive = true;
_launches = 0;
_laser.Init(planet);
ApplyPosition();
TargetRegistry.Register(this);
}

// Duas etapas de movimento: entrada (só a distância diminui) e espiral (ângulo e distância mudam).
private void Update()
{
if (!_alive) return;
float deltaTime = Time.deltaTime;
if (deltaTime <= 0f) return;

if (!_inOrbit)
{
// Entrada: linha reta até a primeira órbita. Ainda não ataca.
_distance = Mathf.Max(_startOrbitRadius, _distance - _entrySpeed * deltaTime);
if (_distance <= _startOrbitRadius)
{
_inOrbit = true;
_laserTimer = 0f;
_launchTimer = 0f;
}
}
else
{
// Espiral: o ângulo avança e o raio encolhe 1,5 u por volta, até a órbita mínima.
// (_angularSpeed / 360) = voltas por segundo; × 1,5 u por volta = u por segundo de encolhimento.
_angle = Mathf.Repeat(_angle + _angularSpeed * deltaTime, 360f);
_distance = Mathf.Max(_minOrbitRadius, _distance - _shrinkPerLap * (_angularSpeed / 360f) * deltaTime);
UpdateAttacks(deltaTime);
}

ApplyPosition();
}

// Dois cronômetros independentes: laser a cada 8 s (se o anterior terminou) e um lançamento a cada 15 s.
private void UpdateAttacks(float deltaTime)
{
_laserTimer += deltaTime;
if (_laserTimer >= _laserInterval && !_laser.IsBusy)
{
_laserTimer = 0f;
_laser.Begin(_laserDamage);
}

_launchTimer += deltaTime;
if (_launchTimer >= _launchInterval)
{
_launchTimer = 0f;
Launch();
}
}

// Um lançamento (Seção 4.5): enquanto houver abertura, _openingCount naves do tipo da vez, espalhadas
// em volta dela; depois, um cacho de Swarmers (o próprio EnemyPool espalha o cacho).
// As naves lançadas escalam com a Wave da Mothership (Seção 4.3).
private void Launch()
{
if (_launches < _openingLaunches.Length)
{
EnemyDefinition definition = _openingLaunches[_launches];
for (int i = 0; i < _openingCount; i++)
{
Vector2 p = Position + UnityEngine.Random.insideUnitCircle * _openingSpread;
_enemies.Spawn(definition, p, _hitpointsMultiplier, _damageMultiplier);
}
}
else
{
_enemies.Spawn(_swarmer, Position, _hitpointsMultiplier, _damageMultiplier);
}
_launches++;
}

// Cumpre ITarget.TakeDamage. Ao morrer: cancela o laser, sai do registro e avisa (o spawner explode e destrói).
public void TakeDamage(float amount, bool isCritical)
{
if (!_alive) return;
CurrentHitpoints -= amount;
if (CurrentHitpoints > 0f) return;

CurrentHitpoints = 0f;
_alive = false;
_laser.Cancel();
TargetRegistry.Unregister(this);
Died?.Invoke(this);
}

// Converte (ângulo, distância) em posição x,y no chão (ver Satellite.SetAngle). O IsoView projeta.
private void ApplyPosition()
{
float radians = _angle * Mathf.Deg2Rad;
Position = WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)) * _distance;
transform.position = new Vector3(WorldLayout.SnapToPixel(Position.x), WorldLayout.SnapToPixel(Position.y), 0f);
}

// Segurança, como no Enemy: se for destruída viva (troca de cena), sai do registro de alvos.
private void OnDisable()
{
if (!_alive) return;
_alive = false;
TargetRegistry.Unregister(this);
}
}
}

Passo 3 — Quem cria a Mothership: MothershipSpawner​

// Caminho: Assets/_Project/Scripts/Enemies/MothershipSpawner.cs
using System;
using System.Collections.Generic;
using Armageddon.Planets;
using Armageddon.Waves;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: alguém precisa ligar o evento "Boss Wave começou" à criação da Mothership, e manter o
// WaveDirector informado de que há um inimigo vivo fora do EnemyPool (senão ele daria Wave Clear
// com o boss ainda em campo).
// ESTRATÉGIA: faz para a Mothership o papel que o EnemyPool faz para os inimigos comuns: cria, guarda
// a lista das vivas, conta no WaveDirector (AddExternalAlive), toca a explosão ao morrer e dispara os
// eventos públicos (MothershipKilled). Sem pool: uma por Boss Wave.
// Cria a Mothership em cada Boss Wave, conta-a como inimiga viva no WaveDirector e toca a explosão ao morrer.
// Pode haver mais de uma: se uma sobreviver até a Boss Wave seguinte, a nova entra junto (Seção 4.5).
public sealed class MothershipSpawner : MonoBehaviour
{
[SerializeField] private Mothership _prefab;
[SerializeField] private WaveDirector _waves;
[SerializeField] private WaveBalance _balance;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private ExplosionPool _explosions;

private readonly List<Mothership> _alive = new List<Mothership>();

public IReadOnlyList<Mothership> Alive => _alive; // a mais antiga é a [0] (a BossHealthBar mostra essa)

public event Action<Mothership> MothershipSpawned;
public event Action<Mothership> MothershipKilled; // Shards (Fase 6) e conquistas (Fase 9)

// Escuta o início das Boss Waves (e para de escutar no OnDisable).
private void OnEnable()
{
_waves.BossWaveStarted += Spawn;
}

private void OnDisable()
{
_waves.BossWaveStarted -= Spawn;
}

// Cria uma Mothership para a Wave "wave". Público: a tecla de teste também chama.
public void Spawn(int wave)
{
// Entra pelo centro de um dos setores do aviso da Boss Wave.
var sectors = _waves.CurrentSectors;
int sector = sectors[UnityEngine.Random.Range(0, sectors.Count)];
float angle = sector * WaveDirector.SectorArc + WaveDirector.SectorArc * 0.5f;

var mothership = Instantiate(_prefab, transform);
mothership.Spawn(wave, angle, _balance.HitpointsMultiplier(wave), _balance.DamageMultiplier(wave), _planet, _enemies);
mothership.Died += HandleDied;
_alive.Add(mothership);
_waves.AddExternalAlive(1); // agora o Wave Clear espera por ela
MothershipSpawned?.Invoke(mothership);
}

// Ao morrer: desfaz a inscrição, desconta do WaveDirector, explode, avisa e destrói o objeto.
private void HandleDied(Mothership mothership)
{
mothership.Died -= HandleDied;
_alive.Remove(mothership);
_waves.AddExternalAlive(-1);
_explosions.Play(ExplosionKind.Mothership, mothership.Position);
MothershipKilled?.Invoke(mothership);
Destroy(mothership.gameObject); // uma por Boss Wave: não precisa de pool
}
}
}

Passo 4 — A barra de HP: BossHealthBar​

// Caminho: Assets/_Project/Scripts/UI/BossHealthBar.cs
using Armageddon.Enemies;
using Armageddon.World;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: o boss tem muita vida; o jogador precisa ver o progresso para saber se o dano está bastando.
// ESTRATÉGIA: uma Image de UI em modo "Filled" (a Unity recorta a imagem pela fração fillAmount, de 0 a 1).
// A cada frame, lê a vida da Mothership mais antiga no MothershipSpawner. Ler por frame aqui é simples e
// barato (uma divisão); a HP muda muitas vezes por segundo, então eventos não trariam ganho.
// Barra de HP da Mothership no topo da tela (Seção 4.5). Mostra a mais antiga viva e some quando não há nenhuma.
public sealed class BossHealthBar : MonoBehaviour
{
[SerializeField] private MothershipSpawner _spawner;
[SerializeField] private GameObject _root; // o objeto que contém a barra inteira (fundo + preenchimento)
[SerializeField] private Image _fill;

// Configura a Image por código para não depender de ajustes manuais no Inspector.
private void Awake()
{
_fill.sprite = PixelSprite.White; // a cor vem do Image (magenta, a cor dos inimigos)
_fill.type = Image.Type.Filled;
_fill.fillMethod = Image.FillMethod.Horizontal;
_root.SetActive(false);
}

// Mostra/esconde conforme haja Mothership viva e atualiza a fração de vida.
private void LateUpdate()
{
var alive = _spawner.Alive;
bool visible = alive.Count > 0;
if (_root.activeSelf != visible) _root.SetActive(visible); // só chama SetActive quando muda
if (!visible) return;

var mothership = alive[0];
_fill.fillAmount = mothership.CurrentHitpoints / mothership.MaxHitpoints;
}
}
}

Passo 5 — Shards da Mothership: atualize o RunEconomy​

A Mothership paga 50 Shards (Seção 4.5), também com a chance de dobro do Resource Bonus (Seção 4.4). Substitua o RunEconomy da Fase 6 por esta versão. Ela só acrescenta o campo Motherships e o HandleMothershipKilled:

// Caminho: Assets/_Project/Scripts/Economy/RunEconomy.cs
using System.Collections.Generic;
using Armageddon.Combat;
using Armageddon.Enemies;
using Armageddon.Planets;
using Armageddon.Waves;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE / ESTRATÉGIA: as mesmas da versão da Fase 6 (ver RunEconomy da Fase 6).
// O QUE MUDOU nesta versão (Fase 7):
// - campo novo _motherships, e inscrição no evento MothershipKilled;
// - HandleMothershipKilled paga os 50 Shards do boss;
// - a regra "chance de Shards em dobro do Resource Bonus" foi movida para AwardKill, usada pelos dois tipos de kill.
// POR QUE: com dois lugares pagando kills, a fórmula ficaria duplicada; num método só, uma mudança
// futura na fórmula vale para ambos.
// Cria os Stats, a carteira e a loja da run; transforma kills e Wave Clears em Shards;
// e aplica os valores dos Stats nos Satellites e no planeta sempre que algum muda.
public sealed class RunEconomy : MonoBehaviour
{
[SerializeField] private StatCatalog _catalog;
[SerializeField] private WaveBalance _waveBalance;
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private MothershipSpawner _motherships; // NOVO na Fase 7
[SerializeField] private WaveDirector _waves;
[SerializeField] private int _startingShards = 15; // saldo no início de toda run (Seção 4.2)
[SerializeField] private int[] _satelliteCosts = { 10, 50, 100 }; // 2º, 3º e 4º Satellite (Seção 4.2)

public StatCatalog Catalog => _catalog;
public RunStats Stats { get; private set; }
public ShardWallet Wallet { get; private set; }
public UpgradeService Upgrades { get; private set; }
public SatelliteShop Satellites { get; private set; }

// Cria a economia da run (igual à Fase 6).
private void Awake()
{
Stats = new RunStats(_catalog.Stats);
Wallet = new ShardWallet();
Upgrades = new UpgradeService(Stats, Wallet);
Satellites = new SatelliteShop(_orbit, Wallet, _satelliteCosts);
}

// Liga os eventos (agora também o da Mothership).
private void OnEnable()
{
Stats.StatChanged += HandleStatChanged;
_enemies.EnemyKilled += HandleEnemyKilled;
_motherships.MothershipKilled += HandleMothershipKilled; // NOVO
_waves.WaveCleared += HandleWaveCleared;
}

private void OnDisable()
{
Stats.StatChanged -= HandleStatChanged;
_enemies.EnemyKilled -= HandleEnemyKilled;
_motherships.MothershipKilled -= HandleMothershipKilled; // NOVO
_waves.WaveCleared -= HandleWaveCleared;
}

// Saldo inicial e primeira aplicação dos Stats (igual à Fase 6).
private void Start()
{
Wallet.Add(_startingShards);
ApplyAll();
}

// O Planet Core (Fase 9) chama isto no começo da run.
public void AddModifier(StatModifier modifier)
{
Stats.AddModifier(modifier);
}

// Qualquer Stat mudou: reaplica todos (igual à Fase 6).
private void HandleStatChanged(StatId id)
{
ApplyAll(); // são só 13 números: recalcular tudo é mais simples e à prova de esquecimento
}

// Copia os valores dos Stats para os Satellites e o planeta (igual à Fase 6).
private void ApplyAll()
{
_orbit.SetStats(new SatelliteStats
{
damage = Stats.Value(StatId.Damage),
attackSpeed = Stats.Value(StatId.AttackSpeed),
criticalChance = Stats.Value(StatId.CriticalChance),
criticalFactor = Stats.Value(StatId.CriticalFactor),
attackRange = Stats.Value(StatId.AttackRange),
impetus = Stats.Value(StatId.Impetus),
orbitSpeed = Stats.Value(StatId.OrbitSpeed),
});

_planet.SetMaxHitpoints(Stats.Value(StatId.Hitpoints));
_planet.SetRegeneration(Stats.Value(StatId.Regeneration));
_planet.SetDefense(Stats.Value(StatId.DefenseAbsolute), Stats.Value(StatId.DefenseRelative));
}

// Shards por kill (Seção 4.4).
private void HandleEnemyKilled(Enemy enemy)
{
AwardKill(enemy.Definition.Shards);
}

// NOVO: a Mothership paga os seus Shards pela mesma regra dos inimigos comuns.
private void HandleMothershipKilled(Mothership mothership)
{
AwardKill(mothership.Shards);
}

// NOVO: a fórmula de Shards por kill, num lugar só.
private void AwardKill(float baseShards)
{
// UnityEngine.Random.value = número aleatório entre 0 e 1.
bool doubled = UnityEngine.Random.value < Stats.Value(StatId.ResourceBonus);
Wallet.Add(doubled ? baseShards * 2f : baseShards);
}

// Bônus de Wave Clear por Wave paga: (5 + w) × (1 + Resource per Wave) (Seção 4.3).
private void HandleWaveCleared(IReadOnlyList<int> paidWaves, bool beforeTimer)
{
float multiplier = 1f + Stats.Value(StatId.ResourcePerWave);
foreach (int wave in paidWaves) Wallet.Add(_waveBalance.ClearBonus(wave) * multiplier);
}
}
}

No Inspector do RunEconomy, preencha o campo novo Motherships com o MothershipSpawner (Passo 7).

Passo 6 — Tecla de teste: MothershipDevTools​

// Caminho: Assets/_Project/Scripts/Enemies/MothershipDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.Waves;
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Enemies
{
// POR QUE: esperar até a Wave 10 para ver a Mothership tornaria cada teste do boss muito lento.
// ESTRATÉGIA: componente de cena (mesma regra do CombatDevTools) que chama o mesmo Spawn que a Boss Wave
// usa, com a escalada da Wave atual, e registra a morte no Console.
// X = cria uma Mothership agora, com a escalada da Wave atual. Registra a morte no Console.
public sealed class MothershipDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private MothershipSpawner _spawner;
[SerializeField] private WaveDirector _waves;

// Escuta a morte das Motherships (e para de escutar no OnDisable).
private void OnEnable()
{
_spawner.MothershipKilled += HandleKilled;
}

private void OnDisable()
{
_spawner.MothershipKilled -= HandleKilled;
}

// Max(1, ...) evita a Wave 0 (antes de a run começar), que daria escalada abaixo da Wave 1.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard != null && keyboard.xKey.wasPressedThisFrame) _spawner.Spawn(Mathf.Max(1, _waves.CurrentWave));
}

// Escreve a morte no Console.
private void HandleKilled(Mothership mothership)
{
Debug.Log($"[Boss] Mothership da Wave {mothership.Wave} destruída (+{mothership.Shards} Shards).");
}
#endif
}
}

Passo 7 — Import dos tiles do laser, prefab e cena​

  1. Mesh Type dos tiles do laser: selecione Art/VFX/SPR_VFX_Laser_Warning e SPR_VFX_Laser_Beam. No Inspector, em Advanced, troque Mesh Type para Full Rect e clique Apply. Sem isso, o modo Tiled do SpriteRenderer não repete o tile, e a Unity mostra um aviso amarelo.

  2. Prefab da Mothership: objeto vazio Mothership com os componentes abaixo, e um filho vazio View com Sprite Renderer (célula 0 de Art/Enemies/SPR_Enemy_Mothership; Sorting Layer World), Sprite Animator (as 5 células; Fps 5; Loop marcado, Seção 6.4) e Iso View (Shadow Radius 1.5). Na raiz:

    • Laser Attack: Warning Frames = as 2 células de SPR_VFX_Laser_Warning; Beam Frames = as 4 de SPR_VFX_Laser_Beam; Impact Frames = as 4 de SPR_VFX_Laser_Impact.
    • Mothership: Opening Launches com 4 itens, nesta ordem: Enemy_Grunt, Enemy_Grunt, Enemy_Scout, Enemy_Scout; Swarmer = Enemy_Swarmer. O resto já vem com os valores da Seção 4.5.

    Arraste para Prefabs/Gameplay/ e apague da cena.

  3. Explosão da Mothership: no ExplosionPool (Fase 4), acrescente um 3º item em Explosions: Mothership, com as 6 células de Art/VFX/SPR_VFX_Explosion_Mothership e Fps 12.

  4. Em [Systems]:

    • MothershipSpawner, com o componente MothershipSpawner: Prefab = Mothership; Waves = WaveDirector; Balance = WaveBalance; Enemies = EnemyPool; Planet = o PlanetHealth; Explosions = ExplosionPool.
    • MothershipDevTools, com o componente MothershipDevTools: Spawner = MothershipSpawner; Waves = WaveDirector.
    • No RunEconomy, Motherships = MothershipSpawner.
  5. Barra de HP: no HUDCanvas (Fase 6), crie um objeto de UI BossHealthBar com o componente BossHealthBar, e dentro dele:

    • Root: uma UI → Image ancorada em cima e centralizada (top center), 200 × 12, Pos Y −4, com SPR_UI_Panel em Image Type Sliced.
    • Dentro de Root: Fill, uma UI → Image que preenche o painel com margem de 3 px (stretch-stretch, Left/Right/Top/Bottom = 3), com Color magenta #FF78DC.
    • Também dentro de Root: um TMP Mothership (m5x7, 16) logo abaixo da barra.

    No BossHealthBar: Spawner = MothershipSpawner; Root = Root; Fill = Fill.

Passo 8 — Commit​

git add . e git commit -m "Phase 7: mothership, spiral orbit, telegraphed laser, boss health bar".

✅ Checkpoint:

  • X cria uma Mothership fora da tela. Ela entra em linha reta, e a barra magenta aparece no topo da tela. Chegando a 7 u, começa a girar devagar no sentido anti-horário, cada volta um pouco mais perto, até parar de se aproximar numa órbita de 3,5 u (uma elipse na tela). Na metade de cima da órbita, ela passa atrás do planeta; a sombra grande dela no grid mostra onde está.
  • Com o alcance base (4 u), os Satellites só começam a atingi-la depois de cerca de uma volta. Com o Range comprado, antes. A barra de HP desce a cada acerto.
  • A cada 8 s aparece uma linha vermelha tracejada piscando entre ela e o planeta (na tela, do sprite dela até a borda do sprite do planeta), que acompanha o movimento. Depois de 1,5 s, vira um feixe com núcleo branco e um brilho na borda do planeta, e o HP do planeta cai 25 (na Wave 1; F6 mostra o valor). Com Defense % comprada, o dano cai.
  • A cada 15 s, ela solta naves de onde está: 4 Grunts, 4 Grunts, 4 Scouts e 4 Scouts nos 4 primeiros lançamentos, e um cacho de 8 Swarmers em cada lançamento depois disso.
  • Destruída, ela solta a explosão grande, a barra some, o saldo sobe 50 Shards e o Console mostra [Boss] Mothership da Wave … destruída.
  • Com N até a Wave 10, a Mothership nasce sozinha do lado da seta do aviso. Se o timer de 60 s acabar com ela viva, a Wave 11 começa com ela ainda em campo, e o Wave Clear só acontece depois que ela e todos os outros morrerem, pagando as Waves acumuladas juntas.
  • Esc congela a espiral, o laser (inclusive no meio do aviso) e o timer dos lançamentos.

Problemas comuns:

  • O laser aparece como um único tile esticado ou borrado: o Mesh Type do tile não está em Full Rect (item 1 do Passo 7).
  • O laser não aparece, mas o planeta perde HP: as listas de frames do LaserAttack estão vazias.
  • A Mothership nunca é atingida: confira o Radius dela (1,5) e se o TargetSelector é a versão da Fase 3 que mede até a borda do alvo (distância − Radius).
  • A Wave nunca termina depois que a Mothership morre: o MothershipSpawner não chamou AddExternalAlive(-1), e o Console costuma mostrar um erro no HandleDied. Confira também se ele é o único a criar Motherships (não arraste o prefab direto na cena).
  • NullReferenceException no RunEconomy ao dar Play: o campo novo Motherships está vazio.
  • Os Swarmers da Mothership não aparecem: o campo Swarmer do prefab está vazio.
  • Ela já começa soltando Swarmers: a lista Opening Launches do prefab está vazia.
  • NullReferenceException no primeiro lançamento: um item da lista Opening Launches está vazio.

Próxima fase: Fase 8 — Run: pausa, HUD e Results. Ela junta tudo num ciclo de run completo: começar, pausar com a tela de pausa, morrer e mostrar o Results.


Fase 8 — Run: pausa, HUD e Results​

Objetivo desta fase: a run tem começo, meio e fim. O HUD mostra o HP do planeta, a Wave e o timer, e tem um botão de pausa que abre a tela de pausa (continuar, Settings, desistir). Quando o planeta chega a 0 HP, o jogo congela e aparece o Results com o resumo da run e os botões "jogar de novo" e "menu". Não existe Revive (Seção 4.6).

Conceitos novos:

  • Controlador da run (RunController): o único dono do estado da run (Running ou Ended). As telas não decidem nada: elas mostram o estado e chamam métodos como GiveUp ou PlayAgain. Assim, a regra "a run acabou: pare as Waves, congele o mundo e mostre o resumo" fica num lugar só.
  • Resumo da run (RunSummary): um objeto de dados com tudo o que aconteceu na run (Wave, kills, tempo, Shards). O Results mostra, e a Fase 9 usa o mesmo objeto para as conquistas e o recorde de Wave.

Diferença para o 8-Bit Armageddon: lá, esta fase cria a interface de anúncios (IAdService), a oferta de Revive (ReviveOfferView) e a onda de choque (ShockwaveView). Este jogo não tem nenhum dos três (Seção 7.1), e o RunController fica com dois estados em vez de três.

Passo 1 — O resumo e o controle da run: RunSummary, RunController​

// Caminho: Assets/_Project/Scripts/Run/RunSummary.cs
using System;

namespace Armageddon.Run
{
// POR QUE: no fim da run, várias partes precisam dos mesmos números (Results, conquistas e recorde
// na Fase 9). Passar cada número separado seria frágil.
// ESTRATÉGIA: um objeto de dados puro (como o PlayerProfile), criado uma vez pelo RunController e
// entregue no evento RunEnded. [Serializable] deixa pronto para salvar como JSON, se preciso.
// O que aconteceu na run. O Results mostra; a Fase 9 usa para conquistas e recorde.
[Serializable]
public sealed class RunSummary
{
public int waveReached;
public int enemiesDestroyed; // inimigos comuns + Motherships
public int mothershipsDestroyed;
public float survivalSeconds;
public float shardsEarned; // todos os Shards ganhos, inclusive os gastos (mostrado no Results, Seção 6.3)
public bool gaveUp;
}
}
// Caminho: Assets/_Project/Scripts/Run/RunController.cs
using System;
using Armageddon.Core;
using Armageddon.Economy;
using Armageddon.Enemies;
using Armageddon.Planets;
using Armageddon.Waves;
using UnityEngine;

namespace Armageddon.Run
{
// POR QUE: o fim da run envolve vários sistemas ao mesmo tempo: "quando o planeta morre, congela o mundo,
// para as Waves e mostra o Results". Se cada tela decidisse um pedaço, a run poderia acabar duas vezes
// (ex.: o jogador desistir no mesmo frame em que o planeta morre).
// ESTRATÉGIA: o único dono do estado da run (Running → Ended), numa máquina de estados mínima (ver
// WaveDirector). As telas só leem State, escutam o evento RunEnded e chamam métodos públicos (GiveUp,
// PlayAgain, GoToMenu). Ele também conta kills e tempo para montar o RunSummary.
// Ciclo de vida da run (Seção 4.6): começa e termina no Results. Não há Revive.
public sealed class RunController : MonoBehaviour
{
public enum RunState { Running, Ended }

[SerializeField] private PlanetHealth _planet;
[SerializeField] private WaveDirector _waves;
[SerializeField] private RunEconomy _economy;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private MothershipSpawner _motherships;

private int _enemiesDestroyed;
private int _mothershipsDestroyed;

public RunState State { get; private set; } = RunState.Running;
public float SurvivalSeconds { get; private set; }

public event Action<RunSummary> RunEnded;

// Escuta a morte do planeta e as kills (para as estatísticas).
private void OnEnable()
{
_planet.Died += HandlePlanetDied;
_enemies.EnemyKilled += HandleEnemyKilled;
_motherships.MothershipKilled += HandleMothershipKilled;
}

private void OnDisable()
{
_planet.Died -= HandlePlanetDied;
_enemies.EnemyKilled -= HandleEnemyKilled;
_motherships.MothershipKilled -= HandleMothershipKilled;
}

// Começa a run. Start (e não Awake) para que todos os sistemas já tenham feito seu Awake.
private void Start()
{
_waves.StartRun(); // o WaveDirector fica com "Auto Start" desligado: quem começa a run é este script
}

// Conta o tempo de sobrevivência (Time.deltaTime é 0 na pausa, então a pausa não conta).
private void Update()
{
if (State == RunState.Running) SurvivalSeconds += Time.deltaTime;
}

// Contadores para o RunSummary.
private void HandleEnemyKilled(Enemy enemy) => _enemiesDestroyed++;

private void HandleMothershipKilled(Mothership mothership)
{
_enemiesDestroyed++;
_mothershipsDestroyed++;
}

// HP chegou a 0: o jogo congela e a run acaba (Seção 4.6).
// A pausa "Modal" (ver GameClock) congela o mundo sem abrir a tela de pausa.
private void HandlePlanetDied()
{
if (State != RunState.Running) return;
Services.Clock.Pause(PauseReason.Modal);
EndRun(gaveUp: false);
}

// Botão "desistir" da tela de pausa.
// Troca a pausa Manual pela Modal: a tela de pausa some (ela só aparece na Manual), mas o mundo continua parado.
public void GiveUp()
{
if (State != RunState.Running) return;
Services.Clock.Resume(PauseReason.Manual);
Services.Clock.Pause(PauseReason.Modal); // o mundo continua congelado atrás do Results
EndRun(gaveUp: true);
}

// Botões do Results. Recarregar a Gameplay recria tudo do zero (o GameClock limpa as pausas ao carregar).
public void PlayAgain() => Services.Scenes.Load(GameScene.Gameplay);
public void GoToMenu() => Services.Scenes.Load(GameScene.MainMenu);

// Encerra a run: para as Waves e entrega o resumo a quem estiver ouvindo (ResultsView, RunDevTools, Fase 9).
private void EndRun(bool gaveUp)
{
State = RunState.Ended;
_waves.StopRun();
RunEnded?.Invoke(new RunSummary
{
waveReached = _waves.CurrentWave,
enemiesDestroyed = _enemiesDestroyed,
mothershipsDestroyed = _mothershipsDestroyed,
survivalSeconds = SurvivalSeconds,
shardsEarned = _economy.Wallet.TotalEarned,
gaveUp = gaveUp,
});
}
}
}

Passo 2 — As telas da run: HudView, PauseView, ResultsView​

// Caminho: Assets/_Project/Scripts/UI/HudView.cs
using Armageddon.Core;
using Armageddon.Planets;
using Armageddon.Waves;
using Armageddon.World;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: o jogador precisa ver o estado da run o tempo todo (HP, Wave, tempo restante) e ter como pausar.
// ESTRATÉGIA: uma "view" (tela) que só LÊ o PlanetHealth e o WaveDirector a cada frame; não decide nada.
// O timer muda todo segundo e o HP várias vezes por segundo, então ler por frame é simples; para não
// gerar lixo de memória, os textos só são reescritos quando o número exibido muda.
// HUD da run (Seção 6.3): HP do planeta, Wave e timer, e o botão de pausa.
// A barra da Mothership é o BossHealthBar (Fase 7).
public sealed class HudView : MonoBehaviour
{
[SerializeField] private PlanetHealth _planet;
[SerializeField] private WaveDirector _waves;
[SerializeField] private Image _healthFill;
[SerializeField] private TMP_Text _healthText;
[SerializeField] private TMP_Text _waveText;
[SerializeField] private TMP_Text _timerText;
[SerializeField] private Button _pauseButton;

// Só reescreve os textos quando o número muda: evita criar strings novas a cada frame.
// Os valores iniciais (-1, -2) são "impossíveis", para forçar a primeira escrita.
private int _shownHealth = -1;
private int _shownMaxHealth = -1;
private int _shownWave = -1;
private int _shownSeconds = -2; // -1 já tem significado (timer vazio durante o aviso)

// Configura a barra (como a BossHealthBar) e liga o botão de pausa ao GameClock.
private void Awake()
{
_healthFill.sprite = PixelSprite.White;
_healthFill.type = Image.Type.Filled;
_healthFill.fillMethod = Image.FillMethod.Horizontal;
_pauseButton.onClick.AddListener(() => Services.Clock.Pause(PauseReason.Manual));
}

// Atualiza barra e textos depois de todos os Update do frame (valores já atualizados).
private void LateUpdate()
{
_healthFill.fillAmount = _planet.Max > 0f ? _planet.Current / _planet.Max : 0f;

int health = Mathf.CeilToInt(_planet.Current); // arredonda para cima: 0,3 HP ainda mostra "1"
int maxHealth = Mathf.CeilToInt(_planet.Max);
if (health != _shownHealth || maxHealth != _shownMaxHealth)
{
_shownHealth = health;
_shownMaxHealth = maxHealth;
_healthText.text = $"{health}/{maxHealth}";
}

if (_waves.CurrentWave != _shownWave)
{
_shownWave = _waves.CurrentWave;
_waveText.text = $"Wave {_shownWave}";
}

int seconds = _waves.IsWarning ? -1 : Mathf.CeilToInt(_waves.TimeRemaining);
if (seconds != _shownSeconds)
{
_shownSeconds = seconds;
_timerText.text = seconds < 0 ? "" : $"{seconds}s";
}
}
}
}
// Caminho: Assets/_Project/Scripts/UI/PauseView.cs
using Armageddon.Core;
using Armageddon.Run;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: a pausa manual precisa de uma tela com "continuar" e "desistir"; sem ela, o jogador pausado
// não teria como sair da run.
// ESTRATÉGIA: a tela não guarda estado próprio: a cada frame, calcula "devo aparecer?" a partir do
// GameClock e do RunController, e só liga/desliga o _root quando a resposta muda. Os botões chamam
// o GameClock (continuar) e o RunController (desistir).
// Tela de pausa (Seção 6.3): continuar, Settings (Fase 11) e desistir da run.
// Aparece com a pausa MANUAL (botão, Esc ou a aba perdeu o foco) enquanto a run está rodando.
public sealed class PauseView : MonoBehaviour
{
[SerializeField] private RunController _run;
[SerializeField] private GameObject _root; // o filho com fundo escuro + painel; este objeto em si fica sempre ativo
[SerializeField] private Button _continueButton;
[SerializeField] private Button _settingsButton;
[SerializeField] private Button _giveUpButton;

// Liga os botões e começa escondida.
private void Awake()
{
_continueButton.onClick.AddListener(() => Services.Clock.Resume(PauseReason.Manual));
_giveUpButton.onClick.AddListener(() => _run.GiveUp());
_settingsButton.interactable = false; // a tela de Settings entra na Fase 11
_root.SetActive(false);
}

// Verificar a cada frame (2 booleanos) é mais simples e mais seguro do que acompanhar
// todas as combinações de pausa manual, pausa modal e estado da run.
// (LateUpdate continua rodando com timeScale = 0; só o deltaTime vira 0.)
private void LateUpdate()
{
bool visible = Services.IsReady
&& Services.Clock.IsPausedBy(PauseReason.Manual)
&& _run.State == RunController.RunState.Running;
if (_root.activeSelf != visible) _root.SetActive(visible);
}
}
}
// Caminho: Assets/_Project/Scripts/UI/ResultsView.cs
using Armageddon.Run;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: o fim da run precisa de um fechamento: o que o jogador conseguiu e para onde ir depois.
// ESTRATÉGIA: uma view que só reage ao evento RunEnded e escreve os números do RunSummary. Os botões
// pedem ao RunController para trocar de cena. Não calcula nada.
// Results (Seção 6.3): estatísticas da run e os botões "jogar de novo" e "menu".
// O recorde de Wave entra na Fase 9, junto com o save do Best Wave.
public sealed class ResultsView : MonoBehaviour
{
[SerializeField] private RunController _run;
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text _waveText;
[SerializeField] private TMP_Text _enemiesText;
[SerializeField] private TMP_Text _timeText;
[SerializeField] private TMP_Text _shardsText;
[SerializeField] private Button _playAgainButton;
[SerializeField] private Button _menuButton;

// Liga os botões e começa escondida.
private void Awake()
{
_playAgainButton.onClick.AddListener(() => _run.PlayAgain());
_menuButton.onClick.AddListener(() => _run.GoToMenu());
_root.SetActive(false);
}

// Aparece quando a run termina (inscreve no evento RunEnded).
private void OnEnable() => _run.RunEnded += Show;
private void OnDisable() => _run.RunEnded -= Show;

// Preenche os textos com o resumo e mostra a tela.
private void Show(RunSummary summary)
{
int seconds = Mathf.FloorToInt(summary.survivalSeconds);
_waveText.text = $"Wave {summary.waveReached}";
_enemiesText.text = summary.enemiesDestroyed.ToString();
// m:ss — divisão inteira dá os minutos; o resto (%) dá os segundos; ":00" força 2 dígitos.
_timeText.text = $"{seconds / 60}:{seconds % 60:00}";
_shardsText.text = Mathf.FloorToInt(summary.shardsEarned).ToString();
_root.SetActive(true);
}
}
}

Passo 3 — Teclas de teste: RunDevTools​

// Caminho: Assets/_Project/Scripts/Run/RunDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.Planets;
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Run
{
// POR QUE: testar o fim da run sem esperar o planeta cair de verdade.
// ESTRATÉGIA: componente de cena (mesma regra do CombatDevTools). Mata o planeta com um dano enorme,
// que passa pelo mesmo caminho do dano real, e escreve o resumo no Console.
// L = zera o HP do planeta.
public sealed class RunDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private PlanetHealth _planet;
[SerializeField] private RunController _run;

// Escuta o fim da run para escrever o resumo no Console.
private void OnEnable() => _run.RunEnded += HandleRunEnded;
private void OnDisable() => _run.RunEnded -= HandleRunEnded;

// Lê a tecla de teste.
private void Update()
{
var keyboard = Keyboard.current;
if (keyboard != null && keyboard.lKey.wasPressedThisFrame) _planet.TakeDamage(1000000f);
}

// Escreve o RunSummary no Console para conferir os números do Results.
private void HandleRunEnded(RunSummary summary)
{
Debug.Log($"[Run] Fim: Wave {summary.waveReached}, {summary.enemiesDestroyed} inimigos, " +
$"{summary.survivalSeconds:0}s, {summary.shardsEarned:0} Shards, desistiu: {summary.gaveUp}");
}
#endif
}
}

Passo 4 — Montar na cena​

  1. Lógica: no WaveDirector, desmarque Auto Start (agora quem começa a run é o RunController). Em [Systems], crie:
    • RunController, com o componente RunController: Planet = o PlanetHealth; Waves, Economy, Enemies, Motherships = os objetos das fases anteriores.
    • RunDevTools, com o componente RunDevTools: Planet e Run.
  2. HUD (no HUDCanvas da Fase 6), em pixels da tela de 320×180:
    • HealthBar, ancorado em cima à esquerda (top left), Pos (4, −4), 80 × 8: uma Image de fundo com SPR_UI_Panel (Sliced) e, dentro, uma Image Fill com margem de 2 px e Color laranja #F28E17 (a cor do "meu"). Ao lado, o TMP HealthText (m5x7, 16).
    • Em cima à direita (top right): o TMP WaveText e, abaixo dele, o TimerText, alinhados à direita; e um Button PauseButton de 16 × 16 (SPR_UI_Button, Sliced) com o texto II.
    • Um objeto HUD com o componente HudView, com todos esses campos ligados.
  3. Pausa: no HUDCanvas, um objeto PauseView com o componente PauseView e um filho Root: uma Image preta com alfa ~50% cobrindo a tela inteira (para escurecer o jogo e bloquear os cliques) e, no centro, um painel SPR_UI_Panel (Sliced) de 120 × 80 com o título Paused e três botões de 100 × 16 (SPR_UI_Button): Continue, Settings e Give up. Ligue os campos.
  4. Results: ResultsView com o Root (fundo escuro e painel de 160 × 120), o título Results, as linhas Wave, Enemies, Time e Shards (um TMP de rótulo e um TMP de valor em cada) e dois botões: Play again e Menu.
  5. Ordem de desenho do Canvas: na hierarquia do HUDCanvas, deixe nesta ordem, de cima para baixo: HUD, BossHealthBar, UpgradeDrawer, PauseView, ResultsView. Na UI, quem vem depois na lista é desenhado por cima.

Os textos das telas estão em inglês porque o inglês é o idioma base da tabela de Localization (Fase 1). A Fase 11 troca cada texto por uma chave traduzida.

Passo 5 — Commit​

git add . e git commit -m "Phase 8: run controller, pause screen, HUD, results".

✅ Checkpoint:

  • O HUD mostra 100/100 e a barra laranja cheia, Wave 1 e o timer contando de 30s para baixo (durante o aviso de 2 s, o timer fica vazio).
  • O botão II (ou Esc) pausa o jogo e abre a tela de pausa. Continue volta. Give up leva direto ao Results.
  • L zera o HP: o jogo congela na hora e aparece o Results, sem nenhuma oferta no meio. O grid, o planeta e os inimigos continuam visíveis, parados, atrás do painel.
  • O Results mostra a Wave alcançada, os inimigos destruídos, o tempo (m:ss) e os Shards ganhos (incluindo os gastos). O Console repete o resumo ([Run] Fim: …).
  • Play again recomeça a run do zero: HP cheio, Wave 1, saldo 15, sem pausa presa. Menu vai para o Main Menu.
  • Com o Results na tela, Esc não abre a pausa por cima.

Problemas comuns:

  • A run começa duas vezes (duas Waves 1 juntas): o Auto Start do WaveDirector continua marcado.
  • Depois do Play again, o jogo continua congelado: o GameClock é o da versão antiga da Fase 1, que só limpava as pausas ao sair da Gameplay. A versão atual limpa a cada cena carregada (SetGameplayActive).
  • Os botões das telas não respondem: a tela está atrás de outra no Canvas (a ordem da hierarquia, item 5 do Passo 4), ou o fundo escuro de outra tela invisível está com Raycast Target marcado e ativo.
  • O Results aparece, mas os inimigos continuam andando atrás dele: o HandlePlanetDied não pausou com PauseReason.Modal. Confira se o RunController está igual ao do Passo 1.

Próxima fase: Fase 8A — Gunner, Multishot, Energy Shield e Reflect Shield. Ela traz as novidades de combate do Orbitfall: o inimigo que atira, o Multishot e os dois escudos.


Fase 8A — Gunner, Multishot, Energy Shield e Reflect Shield​

Objetivo desta fase: entram as novidades de combate do Orbitfall (Seções 4.1, 4.2 e 4.4). Os Satellites ganham o Multishot (às vezes atiram em mais de um inimigo de uma vez); o planeta ganha o Energy Shield (cargas que anulam golpes inteiros) e o Reflect Shield (o Reflector, um escudo-satélite que devolve tiros); e chega o Gunner, o primeiro inimigo que não é kamikaze: ele para fora do planeta e atira Gunner Bolts. São 4 Stats novos na gaveta, que passa a ter 17.

Chão e vista: como em todo o guia, cada objeto novo desta fase tem a raiz no chão, com os scripts de regra, e o desenho num filho View com o IsoView (Fase 2). Os scripts de regra são os mesmos do Orbitfall top-down; só o desenho do arco do Reflector e do laser bloqueado passam pela projeção.

Por que "8A"? As Fases 9 a 15 já são citadas pelo resto do guia (save, menus, anúncios...). Esta fase e a 8B (o modo Invasion) entram entre a 8 e a 9 sem mudar esses números. Elas só dependem do que foi feito até a Fase 8.

Conceitos novos:

  • Enum guardado como número: o Unity salva um enum num asset como o número da opção (Damage = 0, AttackSpeed = 1...). Por isso os 4 Stats novos entram no fim do StatId: no meio, eles empurrariam os números dos Stats antigos, e os assets da Fase 6 passariam a apontar para o Stat errado. A mesma regra vale para o ExplosionKind.
  • Exceções dentro da definição: o Energy Shield tem preço fixo por nível (30, 90, 250), e o Reflect Shield vale 0 no nível 0 e 15 no nível 1. Em vez de criar classes novas de Stat, o StatDefinition ganha três campos opcionais (tabela de preços, bônus do primeiro nível e "número inteiro"). Os 13 Stats antigos deixam esses campos vazios e continuam iguais.
  • Inimigo com dois comportamentos: o Enemy continua sendo um prefab só. A EnemyDefinition diz se ele é kamikaze (Hold Distance = 0) ou se para a uma distância e atira (o Gunner).
  • Um "registro" para quem é único: o Reflector é perguntado por três sistemas (o tiro do Gunner, o laser da Mothership e os kamikazes). Em vez de arrastar o Reflector no Inspector de cada prefab, ele se publica numa propriedade estática, Reflector.Current, como o TargetRegistry faz com os alvos.
  • O escudo vem antes da defesa: o PlanetHealth pergunta ao EnergyShield se ele absorve o golpe antes de aplicar a fórmula de defesa. Um golpe absorvido não passa por ela.

Passo 1 — Os 4 Stats novos: StatDefinition e RunStats​

Substitua o StatDefinition da Fase 6 por esta versão. Ela acrescenta os 4 Stats no fim do StatId e os três campos opcionais:

// Caminho: Assets/_Project/Scripts/Economy/StatDefinition.cs
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: cada Stat precisa de um identificador que o código use sem depender de nomes de asset.
// ESTRATÉGIA: enum; os assets StatDefinition dizem "eu sou o Stat X" por este id.
// Os 17 Stats da Seção 4.2. Os 4 da Fase 8A ficam NO FIM: o Unity guarda o enum no asset como número,
// e inserir no meio mudaria o número (e portanto o Stat) de todos os assets que vêm depois.
public enum StatId
{
Damage, AttackSpeed, CriticalChance, CriticalFactor, AttackRange, Impetus, OrbitSpeed,
Hitpoints, Regeneration, DefenseAbsolute, DefenseRelative,
ResourceBonus, ResourcePerWave,
MultishotChance, MultishotTargets, EnergyShield, ReflectShield, // Fase 8A
}

// Em qual Track (aba da gaveta) o Stat aparece.
public enum StatTrack { Offense, Defense, Utility }

// Como o valor aparece no card.
public enum StatFormat { Number, Percent, PerSecond, Multiplier, Units, DegreesPerSecond, PercentPerUnit }

// POR QUE: os 17 Stats seguem a mesma regra (base + incremento × nível, teto, custo crescente); só os
// números mudam. É a "definição" (o que o Stat É), separada do "estado" (em que nível ele está na run).
// ESTRATÉGIA: ScriptableObject (ver EnemyDefinition), um asset por Stat (Stat_Damage...). Nunca muda
// durante o jogo: quem guarda o nível é o RunStats. Também sabe se formatar para o card (Format).
// Os três campos de "Casos especiais" existem para o Energy Shield e o Reflect Shield (Seção 4.2); nos
// outros Stats ficam vazios e não mudam nada.
// Um Stat (Seção 4.2). Percentuais são guardados como fração: 1,5 p.p. = 0,015; 0,4 %/u = 0,004.
[CreateAssetMenu(menuName = "Armageddon/Stat Definition", fileName = "Stat_")]
public sealed class StatDefinition : ScriptableObject
{
public const float CostGrowth = 1.15f; // custo = baseCost × 1,15^nível

[SerializeField] private StatId _id;
[SerializeField] private StatTrack _track;
[SerializeField] private string _displayName; // a Fase 11 troca por uma chave de Localization
[SerializeField] private Sprite _icon;
[SerializeField] private float _base; // valor no nível 0
[SerializeField] private float _perLevel; // quanto cada Upgrade soma
[SerializeField] private float _cap; // 0 = sem teto
[SerializeField] private int _baseCost = 5; // custo do primeiro Upgrade
[SerializeField] private StatFormat _format;

[Header("Casos especiais (Fase 8A)")]
[SerializeField] private float _firstLevelBonus; // somado do nível 1 em diante (Reflect Shield: 0 → 15 → 23...)
[SerializeField] private int[] _fixedCosts = new int[0]; // preço de cada nível; vazio = fórmula 1,15^nível
[SerializeField] private bool _wholeNumber; // arredonda para baixo (cargas do Energy Shield)

public StatId Id => _id;
public StatTrack Track => _track;
public string DisplayName => _displayName;
public Sprite Icon => _icon;
public float Base => _base;
public float PerLevel => _perLevel;
public bool HasCap => _cap > 0f;
public float Cap => _cap;
public int BaseCost => _baseCost;
public float FirstLevelBonus => _firstLevelBonus;
public bool WholeNumber => _wholeNumber;

// Tabela de preços: se existe, ela manda no custo E no número máximo de níveis (o tamanho da tabela).
public bool HasFixedCosts => _fixedCosts != null && _fixedCosts.Length > 0;
public int MaxFixedLevel => HasFixedCosts ? _fixedCosts.Length : int.MaxValue; // MaxValue = "sem limite"
public int FixedCost(int level) => _fixedCosts[level];

// Transforma o número em texto para o card, conforme o formato do Stat (ex.: 0,015 → "1.5%").
// "{value:0.#}" = formatação do C#: até 1 casa decimal, sem zeros inúteis.
public string Format(float value)
{
return _format switch
{
StatFormat.Percent => $"{value * 100f:0.#}%",
StatFormat.PerSecond => $"{value:0.##}/s",
StatFormat.Multiplier => $"{value:0.#}x",
StatFormat.Units => $"{value:0.##}u",
StatFormat.DegreesPerSecond => $"{value:0}°/s",
StatFormat.PercentPerUnit => $"{value * 100f:0.#}%/u",
_ => $"{value:0.#}",
};
}
}
}

No RunStats (Fase 6), troque os métodos ValueAtLevel, IsMaxed e NextCost por estes. O resto da classe não muda:

// Seção 4.2: base + incremento × nível (+ o bônus do primeiro nível), limitado pelo teto, e DEPOIS
// modificado pelo Planet Core. Recebe o nível como parâmetro para o card poder mostrar
// "valor atual → valor do próximo nível".
public float ValueAtLevel(StatId id, int level)
{
var definition = _definitions[id];
float value = definition.Base + definition.PerLevel * level;
if (level > 0) value += definition.FirstLevelBonus; // Reflect Shield: 0 no nível 0, 15 no nível 1
if (definition.HasCap) value = Mathf.Min(value, definition.Cap);

// Soma todos os "add" e multiplica todos os "multiply" que se aplicam a este Stat.
float add = 0f, multiply = 1f;
foreach (var modifier in _modifiers)
{
if (modifier.stat != id) continue;
add += modifier.add;
multiply *= modifier.multiply;
}

float result = (value + add) * multiply;
// Cargas são inteiras: o +30% de Defense do Ice Core não vira "1,3 carga" (Seção 4.2).
// Mathf.Floor arredonda para baixo, então 1, 2 e 3 cargas continuam 1, 2 e 3 com o +30%.
return definition.WholeNumber ? Mathf.Floor(result) : result;
}

// Chegou no máximo? Por teto (valor dos Upgrades, antes dos modificadores) ou porque a tabela de
// preços fixos acabou (Energy Shield: 3 níveis).
public bool IsMaxed(StatId id)
{
var definition = _definitions[id];
int level = _levels[id];
if (level >= definition.MaxFixedLevel) return true;
return definition.HasCap && definition.Base + definition.PerLevel * level >= definition.Cap;
}

// Custo do próximo nível: o da tabela fixa, se o Stat tiver uma; senão baseCost × 1,15^nível,
// arredondado para o inteiro mais próximo. No máximo, devolve 0 (ninguém vai comprar).
public int NextCost(StatId id)
{
var definition = _definitions[id];
int level = _levels[id];
if (definition.HasFixedCosts) return level < definition.MaxFixedLevel ? definition.FixedCost(level) : 0;
return Mathf.RoundToInt(definition.BaseCost * Mathf.Pow(StatDefinition.CostGrowth, level));
}

Passo 2 — Multishot: SatelliteStats, TargetSelector, Satellite​

No SatelliteStats (Fase 3), acrescente os dois campos novos e o sorteio, logo depois do orbitSpeed:

[Range(0f, 0.5f)] public float multishotChance; // chance de um disparo virar Multishot (Fase 8A)
public int multishotTargets = 1; // alvos extras de cada Multishot

// Sorteia o Multishot de UM disparo (Seção 4.1). Mesmo jeito do crítico: número aleatório menor que a chance.
public bool RollMultishot() => UnityEngine.Random.value < multishotChance;

Substitua o TargetSelector (o arquivo TargetSelector.cs da Fase 3, com o enum TargetPriority) por esta versão. O Select faz o mesmo de antes; a regra de "alvo válido + nota" foi para um método só (TryScore), que o SelectExtras novo também usa:

// Caminho: Assets/_Project/Scripts/Combat/TargetSelector.cs
using System.Collections.Generic;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: cada Satellite pode ter uma regra diferente de escolha de alvo (Target Priority).
// ESTRATÉGIA: um enum com as 4 regras; o Satellite guarda qual está usando.
// As 4 Target Priorities (Seção 4.1). Closest é a padrão; todas ficam disponíveis desde o início da run.
public enum TargetPriority
{
Closest, // o mais perto do planeta
Weakest, // o com menos HP atual
Strongest, // o com mais HP atual
Farthest, // o mais longe do planeta (sinergia com Impetus)
}

// POR QUE: a regra "quem é um alvo válido e qual deles é o melhor" é o coração do combate e precisa
// ser a mesma para todos os Satellites, no tiro normal e no Multishot.
// ESTRATÉGIA: classe estática com funções puras. TryScore decide se um alvo vale e dá a nota dele;
// Select fica com o melhor; SelectExtras repete a escolha, pulando quem já foi escolhido.
public static class TargetSelector
{
// Só considera alvos vivos, no Quadrant pedido e com a BORDA dentro do alcance (medido do centro do planeta).
// Devolve null se não houver ninguém válido.
// Estratégia: uma passada só pela lista, guardando o "melhor até agora" (menor score).
public static ITarget Select(IReadOnlyList<ITarget> targets, Quadrant quadrant, float range, TargetPriority priority)
{
ITarget best = null;
float bestScore = float.MaxValue; // qualquer score real é menor que isso

// for com índice (em vez de foreach) não aloca memória numa IReadOnlyList: importante num
// código que roda muitas vezes por segundo.
for (int i = 0; i < targets.Count; i++)
{
if (!TryScore(targets[i], quadrant, range, priority, out float score)) continue;
if (score < bestScore)
{
bestScore = score;
best = targets[i];
}
}

return best;
}

// Multishot (Seção 4.1): até "count" alvos válidos ALÉM do principal, na ordem da Target Priority,
// nunca dois no mesmo alvo. Se houver menos alvos válidos, "results" fica com menos itens.
// Estratégia: repete a escolha do Select "count" vezes, pulando o principal e quem já entrou em results.
// Com no máximo 4 extras, são poucas passadas pela lista, e só quando o Multishot é sorteado.
// "results" vem de fora (e é limpo aqui) para não criar uma lista nova a cada disparo.
public static void SelectExtras(IReadOnlyList<ITarget> targets, Quadrant quadrant, float range, TargetPriority priority,
ITarget main, int count, List<ITarget> results)
{
results.Clear();
for (int pick = 0; pick < count; pick++)
{
ITarget best = null;
float bestScore = float.MaxValue;
for (int i = 0; i < targets.Count; i++)
{
var target = targets[i];
if (target == main || results.Contains(target)) continue;
if (!TryScore(target, quadrant, range, priority, out float score)) continue;
if (score < bestScore)
{
bestScore = score;
best = target;
}
}
if (best == null) return; // acabaram os alvos válidos
results.Add(best);
}
}

// A regra de um alvo só: devolve false se ele não vale; senão, a nota dele em "score" (menor vence).
// "out float score": um segundo valor de retorno (ver SatelliteStats.RollDamage).
private static bool TryScore(ITarget target, Quadrant quadrant, float range, TargetPriority priority, out float score)
{
score = 0f;
if (!target.IsAlive) return false;

// sqrMagnitude = distância ao quadrado (mais barata, sem raiz). Serve para comparar distâncias.
float distanceSquared = (target.Position - WorldLayout.PlanetCenter).sqrMagnitude;
// Para o alcance precisamos da distância real, menos o raio (mede até a borda do alvo).
if (Mathf.Sqrt(distanceSquared) - target.Radius > range) return false;
if (Quadrants.FromPosition(target.Position) != quadrant) return false;

// Menor "score" vence: por isso Farthest e Strongest usam o valor negativo.
// "switch expression" (C# 8): escolhe um valor conforme o caso; "_" é o caso padrão.
score = priority switch
{
TargetPriority.Closest => distanceSquared,
TargetPriority.Farthest => -distanceSquared,
TargetPriority.Weakest => target.CurrentHitpoints,
TargetPriority.Strongest => -target.CurrentHitpoints,
_ => distanceSquared,
};
return true;
}
}
}

No Satellite (Fase 3), troque o método Tick por estes três membros (a lista estática, o Tick novo e o Shoot):

// Lista reaproveitada para os alvos extras do Multishot. "static": uma só para todos os Satellites,
// porque eles atiram um de cada vez, dentro do mesmo Update do SatelliteOrbit.
private static readonly List<ITarget> ExtraTargets = new List<ITarget>();

// Chamado pelo SatelliteOrbit a cada frame. Com o cooldown zerado e ninguém válido, segura o tiro.
public void Tick(float deltaTime, SatelliteStats stats, IReadOnlyList<ITarget> targets, ProjectilePool projectiles)
{
if (_cooldown > 0f)
{
_cooldown -= deltaTime;
if (_cooldown > 0f) return;
}

var target = TargetSelector.Select(targets, CurrentQuadrant, stats.attackRange, _priority);
if (target == null)
{
_cooldown = 0f; // pronto para atirar assim que alguém entrar no Quadrant
return;
}

Shoot(target, stats, projectiles);

// Multishot (Seção 4.1): UM sorteio por disparo. Os projéteis extras saem no mesmo instante, e
// cada um sorteia o próprio crítico (o Shoot chama RollDamage de novo). Um extra não sorteia outro.
if (stats.multishotTargets > 0 && stats.RollMultishot())
{
TargetSelector.SelectExtras(targets, CurrentQuadrant, stats.attackRange, _priority,
target, stats.multishotTargets, ExtraTargets);
for (int i = 0; i < ExtraTargets.Count; i++) Shoot(ExtraTargets[i], stats, projectiles);
}

// "+=" em vez de "=": se o cooldown passou um pouco do zero, a sobra é descontada do próximo,
// e a cadência média fica exata mesmo com frames irregulares.
_cooldown += 1f / Mathf.Max(0.01f, stats.attackSpeed);
Fired?.Invoke(this);
}

// Um projétil num alvo: a fórmula de dano da Seção 4.1, com o Impetus pela distância DESTE alvo.
private void Shoot(ITarget target, SatelliteStats stats, ProjectilePool projectiles)
{
float distance = Vector2.Distance(target.Position, WorldLayout.PlanetCenter);
float damage = stats.RollDamage(distance, out bool isCritical);
projectiles.Fire(transform.position, target, damage, isCritical);
}

Passo 3 — O Energy Shield: EnergyShield, EnergyShieldView e o PlanetHealth​

// Caminho: Assets/_Project/Scripts/Planets/EnergyShield.cs
using System;
using UnityEngine;

namespace Armageddon.Planets
{
// POR QUE: o Energy Shield (Seção 4.2) anula golpes INTEIROS, um por carga, e recarrega com o tempo.
// É uma regra diferente da defesa (que só reduz o golpe), então ganha um componente próprio.
// ESTRATÉGIA: fica no objeto Planet, ao lado do PlanetHealth. O PlanetHealth pergunta "você absorve
// este golpe?" (TryAbsorb) ANTES da fórmula de defesa. O máximo de cargas vem do Stat (o RunEconomy
// chama SetMaxCharges); a recarga é um relógio simples no Update. O desenho fica no EnergyShieldView,
// que só escuta os eventos daqui.
public sealed class EnergyShield : MonoBehaviour
{
[SerializeField] private float _rechargeSeconds = 12f; // 1 carga a cada 12 s (valor inicial, Seção 4.2)

private float _timer; // segundos acumulados para a próxima carga

public int MaxCharges { get; private set; }
public int Charges { get; private set; }

public event Action ChargesChanged;
public event Action Broke; // a última carga acabou de ser gasta (o EnergyShieldView toca o estouro)

// Chamado pelo RunEconomy quando o Stat Energy Shield muda. A carga do nível comprado já vem cheia (Seção 4.2).
public void SetMaxCharges(int max)
{
max = Mathf.Max(0, max);
int gained = max - MaxCharges;
MaxCharges = max;
if (gained > 0) Charges += gained;
Charges = Mathf.Min(Charges, MaxCharges);
ChargesChanged?.Invoke();
}

// Cargas cheias e relógio zerado. O modo Invasion chama a cada planeta novo (Fase 8B).
public void Refill()
{
Charges = MaxCharges;
_timer = 0f;
ChargesChanged?.Invoke();
}

// Recarga: enquanto faltar carga, o relógio anda; a cada _rechargeSeconds, volta uma.
// O relógio NÃO reinicia quando o planeta toma golpe (Seção 4.2). Pausado, deltaTime é 0 e ele para sozinho.
private void Update()
{
if (Charges >= MaxCharges)
{
_timer = 0f;
return;
}

_timer += Time.deltaTime;
if (_timer < _rechargeSeconds) return;
_timer -= _rechargeSeconds;
Charges++;
ChargesChanged?.Invoke();
}

// Gasta uma carga, se houver. Padrão "Try" (ver ShardWallet.TrySpend): devolve se conseguiu.
public bool TryAbsorb()
{
if (Charges <= 0) return false;
Charges--;
ChargesChanged?.Invoke();
if (Charges == 0) Broke?.Invoke();
return true;
}
}
}
// Caminho: Assets/_Project/Scripts/Planets/EnergyShieldView.cs
using System;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Planets
{
// POR QUE: o jogador precisa VER quantas cargas restam (Seção 4.2), sem ler um número.
// ESTRATÉGIA: um SpriteRenderer + SpriteAnimator centralizados no planeta, com uma linha de frames
// por número de cargas (SPR_VFX_EnergyShield, Seção 6.4: 1, 2 ou 3 anéis). Só troca de linha quando o
// EnergyShield avisa. O estouro é uma "explosão" do ExplosionPool (tipo ShieldPop), que já sabe tocar
// uma vez e voltar ao pool.
[RequireComponent(typeof(SpriteRenderer), typeof(SpriteAnimator))]
public sealed class EnergyShieldView : MonoBehaviour
{
// Os frames de uma linha da spritesheet (o Unity não serializa Sprite[][], ver SpriteVariant).
[Serializable]
private sealed class ChargeFrames
{
public Sprite[] frames;
}

[SerializeField] private EnergyShield _shield;
[SerializeField] private ExplosionPool _explosions;
[SerializeField] private ChargeFrames[] _rows = new ChargeFrames[3]; // índice 0 = 1 carga
[SerializeField] private float _fps = 6f;

private SpriteRenderer _renderer;
private SpriteAnimator _animator;
private int _shown = -1; // quantas cargas estão desenhadas agora (-1 = nada ainda)

// Cache dos componentes.
private void Awake()
{
_renderer = GetComponent<SpriteRenderer>();
_animator = GetComponent<SpriteAnimator>();
}

private void OnEnable()
{
_shield.ChargesChanged += Refresh;
_shield.Broke += HandleBroke;
Refresh();
}

private void OnDisable()
{
_shield.ChargesChanged -= Refresh;
_shield.Broke -= HandleBroke;
}

// Mostra a linha do número de cargas atual, ou esconde a bolha sem carga nenhuma.
private void Refresh()
{
int charges = Mathf.Min(_shield.Charges, _rows.Length);
if (charges == _shown) return; // nada mudou: não reinicia a animação
_shown = charges;
_renderer.enabled = charges > 0;
if (charges > 0) _animator.SetFrames(_rows[charges - 1].frames, _fps, true);
}

// A última carga foi gasta: o anel estoura (SPR_VFX_EnergyShield_Pop) no centro do planeta.
private void HandleBroke()
{
_explosions.Play(ExplosionKind.ShieldPop, WorldLayout.PlanetCenter);
}
}
}

No ExplosionPool (Fase 4), acrescente o tipo novo no fim do enum (pelo mesmo motivo do StatId):

public enum ExplosionKind { Small, Big, Mothership, ShieldPop }

No PlanetHealth (Fase 2), acrescente o campo do escudo junto dos outros [SerializeField]:

[SerializeField] private EnergyShield _shield; // opcional (Fase 8A): vazio = sem Energy Shield

E, no TakeDamage, logo depois da primeira linha (if (IsDead || raw <= 0f) return;), acrescente:

// Energy Shield (Seção 4.2): uma carga anula o golpe INTEIRO, antes da fórmula de defesa.
if (_shield != null && _shield.TryAbsorb()) return;

Por que a pergunta fica no PlanetHealth e não em quem bate? Kamikazes, Gunner Bolts e o laser chamam todos o mesmo TakeDamage. Com a pergunta ali, nenhum deles precisa saber que o escudo existe, e um ataque novo no futuro já respeita o escudo sem mudar nada.

Passo 4 — O Reflect Shield: Reflector e ReflectorArcView​

// Caminho: Assets/_Project/Scripts/Combat/Reflector.cs
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: o Reflect Shield (Seção 4.2) é um escudo-satélite com regras próprias: órbita de 2,5 u,
// sentido horário, 30°/s, e um arco de 40° (o Reflect Arc) que devolve Gunner Bolts, bloqueia o laser
// da Mothership e fere os kamikazes. Ele NÃO é um Satellite: não atira, não conta no limite de 4 e não
// tem Target Priority.
// ESTRATÉGIA: o Reflector só sabe GEOMETRIA ("este ponto está no meu arco?", "este tiro cruzou meu arco?")
// e devolver tiros. Quem pergunta são os outros: o EnemyBolt, o LaserAttack e o Enemy. Para eles não
// precisarem de uma referência arrastada no Inspector, o Reflector ativo se publica numa propriedade
// estática, Current (como o TargetRegistry faz com os alvos). Com Damage = 0 (nível 0 do Stat), o
// Reflector se esconde e Current fica null: quem pergunta vê "não existe Reflector".
public sealed class Reflector : MonoBehaviour
{
[SerializeField] private float _orbitRadius = 2.5f; // por fora da órbita dos Satellites (1,75 u)
[SerializeField] private float _angularSpeed = 30f; // graus por segundo, sentido HORÁRIO
[SerializeField] private float _arcDegrees = 40f; // o Reflect Arc
[SerializeField] private ProjectilePool _reflectedShots; // pool do tiro devolvido (azul-claro, SPR_Projectile_Reflected)
[SerializeField] private GameObject _visual; // corpo + arco: some quando o Stat está no nível 0

public static Reflector Current { get; private set; }

public float Damage { get; private set; } // Reflect Damage, absoluto (sem crítico nem Impetus)
public float AngleDegrees { get; private set; } = 270f; // começa embaixo do planeta
public float OrbitRadius => _orbitRadius;
public float HalfArc => _arcDegrees * 0.5f;

// Começa desligado: o Stat começa no nível 0. O RunEconomy liga com SetDamage no Start dele.
private void Awake()
{
SetDamage(0f);
Place();
}

// Se o objeto sair de cena (troca de cena), deixa de ser o Current.
private void OnDisable()
{
if (Current == this) Current = null;
}

// Chamado pelo RunEconomy com o valor do Stat Reflect Shield. 0 = sem Reflector.
public void SetDamage(float damage)
{
Damage = Mathf.Max(0f, damage);
bool active = Damage > 0f;
if (_visual != null) _visual.SetActive(active);
if (active) Current = this;
else if (Current == this) Current = null;
}

// Gira no sentido horário (o ângulo DIMINUI; os Satellites giram no anti-horário, o ângulo aumenta).
private void Update()
{
if (Damage <= 0f) return;
AngleDegrees = Mathf.Repeat(AngleDegrees - _angularSpeed * Time.deltaTime, 360f);
Place();
}

// Ângulo de um ponto em volta do planeta, em graus (0 = direita, anti-horário, como em todo o jogo).
public static float AngleOf(Vector2 point)
{
Vector2 offset = point - WorldLayout.PlanetCenter;
return Mathf.Atan2(offset.y, offset.x) * Mathf.Rad2Deg;
}

// O ponto da órbita do Reflector num ângulo (ver Satellite.SetAngle).
public Vector2 PointAt(float degrees)
{
float radians = degrees * Mathf.Deg2Rad;
return WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)) * _orbitRadius;
}

// Este ponto está na direção do Reflect Arc? Só o ÂNGULO conta; a distância, quem pergunta testa.
// Mathf.DeltaAngle dá a menor diferença entre dois ângulos (de −180 a 180): 350° e 10° estão a 20°.
public bool Covers(Vector2 point)
{
return Mathf.Abs(Mathf.DeltaAngle(AngleOf(point), AngleDegrees)) <= HalfArc;
}

// Um tiro andou de "from" para "to" neste frame: ele cruzou o círculo do Reflector de FORA para
// DENTRO, dentro do arco? (Gunner Bolt, Seção 4.2)
public bool Intercepts(Vector2 from, Vector2 to)
{
float before = Vector2.Distance(from, WorldLayout.PlanetCenter);
float after = Vector2.Distance(to, WorldLayout.PlanetCenter);
return before > _orbitRadius && after <= _orbitRadius && Covers(to);
}

// A BORDA de um inimigo está encostando no círculo do Reflector, dentro do arco? (kamikaze, Seção 4.2)
public bool Touches(Vector2 position, float radius)
{
float distance = Vector2.Distance(position, WorldLayout.PlanetCenter);
return distance - radius <= _orbitRadius && distance + radius >= _orbitRadius && Covers(position);
}

// Devolve um tiro: teleguiado para quem atirou ("shooter"); se ele já morreu, para o alvo vivo mais
// perto; se não houver nenhum, o tiro some (Seção 4.2).
public void Reflect(Vector2 from, ITarget shooter)
{
var target = shooter != null && shooter.IsAlive ? shooter : Nearest(from);
if (target != null) _reflectedShots.Fire(from, target, Damage, false);
}

// O alvo vivo mais perto de um ponto (uma passada pela lista, como no TargetSelector).
private static ITarget Nearest(Vector2 from)
{
ITarget best = null;
float bestDistance = float.MaxValue;
var targets = TargetRegistry.Alive;
for (int i = 0; i < targets.Count; i++)
{
if (!targets[i].IsAlive) continue;
float distance = (targets[i].Position - from).sqrMagnitude;
if (distance < bestDistance)
{
bestDistance = distance;
best = targets[i];
}
}
return best;
}

// Move a RAIZ para o ponto da órbita, no chão (o IsoView do filho View projeta na tela, Fase 2).
private void Place()
{
Vector2 p = PointAt(AngleDegrees);
transform.position = new Vector3(WorldLayout.SnapToPixel(p.x), WorldLayout.SnapToPixel(p.y), 0f);
}

// Com "Enter Play Mode Options" (sem recarregar o domínio), o Current estático sobreviveria entre Plays.
// Mesmo mecanismo do TargetRegistry.ResetStatics.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics()
{
Current = null;
}
}
}
// Caminho: Assets/_Project/Scripts/Combat/ReflectorArcView.cs
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Combat
{
// POR QUE: o jogador precisa ver a REGIÃO que o Reflector cobre (o arco de 40°), e não só o corpo dele,
// para entender por que um tiro voltou e o outro passou.
// ESTRATÉGIA: como o rastro do Projectile, o arco é feito de pixels brancos (PixelSprite) tingidos de
// azul-claro, criados uma vez e reposicionados a cada frame ao longo do arco. Cada ponto é calculado no
// CHÃO (onde a regra acontece) e projetado na tela pelo IsoProjection: o arco do chão vira um pedaço de
// elipse no plano de voo. Duas fileiras (a segunda 1 px abaixo, na tela) dão 2 px de espessura.
// Nenhum sprite é girado (Seção 6.2).
public sealed class ReflectorArcView : MonoBehaviour
{
[SerializeField] private Reflector _reflector;
[SerializeField] private int _pointsPerRow = 32; // o arco tem ~28 px de comprimento: 32 pontos não deixam buracos
[SerializeField] private Color _color = new Color32(143, 216, 255, 255);
[SerializeField] private string _sortingLayer = "World";

private SpriteRenderer[] _dots;

// Cria os pixels do arco uma vez só, como filhos deste objeto.
private void Awake()
{
_dots = new SpriteRenderer[_pointsPerRow * 2];
for (int i = 0; i < _dots.Length; i++)
{
var dot = new GameObject("ArcPixel").AddComponent<SpriteRenderer>();
dot.transform.SetParent(transform, false);
dot.sprite = PixelSprite.White;
dot.color = _color;
dot.sortingLayerName = _sortingLayer;
_dots[i] = dot;
}
}

// LateUpdate: depois de o Reflector girar neste frame.
private void LateUpdate()
{
const float pixel = 1f / WorldLayout.PixelsPerUnit;
float start = _reflector.AngleDegrees - _reflector.HalfArc;
float span = _reflector.HalfArc * 2f;
for (int row = 0; row < 2; row++)
{
for (int i = 0; i < _pointsPerRow; i++)
{
// t vai de 0 a 1 ao longo do arco: o ângulo anda de "start" até "start + span".
float t = i / (float)(_pointsPerRow - 1);
float radians = (start + span * t) * Mathf.Deg2Rad;
Vector2 ground = WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)) * _reflector.OrbitRadius;
Vector2 view = IsoProjection.GroundToView(ground) + Vector2.down * (row * pixel);
_dots[row * _pointsPerRow + i].transform.position = IsoProjection.Snap(view);
}
}
}
}
}

Passo 5 — O Gunner: EnemyDefinition, EnemyBolt, BoltPool e o Enemy​

Na EnemyDefinition (Fase 4), acrescente este bloco logo depois do bloco [Header("Spawn (usados pelas Waves, Fase 5)")] e dos 4 campos dele:

[Header("Ataque à distância (Gunner, Fase 8A)")]
[SerializeField] private float _holdDistance; // 0 = kamikaze; > 0 = para a esta distância do centro e atira
[SerializeField] private float _boltInterval = 2.5f; // segundos entre dois Gunner Bolts (Seção 4.4)

e, junto das outras propriedades:

public float HoldDistance => _holdDistance;
public bool IsRanged => _holdDistance > 0f; // o Gunner; os outros são kamikaze
public float BoltInterval => _boltInterval;
// Caminho: Assets/_Project/Scripts/Enemies/EnemyBolt.cs
using System;
using Armageddon.Combat;
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Enemies
{
// POR QUE: o Gunner (Seção 4.4) ataca à distância com um projétil que pode ser anulado pelo Energy
// Shield e devolvido pelo Reflector. Diferente do projétil do Satellite, ele NÃO é teleguiado (vai em
// linha reta para o planeta) e não é alvo dos Satellites (não implementa ITarget).
// ESTRATÉGIA: o mesmo molde do Projectile (Fase 3): anda sozinho, recebe tudo no Launch e se devolve ao
// pool por uma função "release". A cada passo, pergunta ao Reflector se cruzou o arco dele; se não, e
// se chegou à borda do planeta, causa o dano pelo PlanetHealth (que consulta o Energy Shield).
public sealed class EnemyBolt : MonoBehaviour
{
[SerializeField] private float _speed = 4f; // u/s (Seção 4.4)

private PlanetHealth _planet;
private ITarget _shooter; // quem atirou: para onde o Reflector devolve o tiro
private float _damage;
private Vector2 _position; // posição exata (o transform fica preso à grade de pixels)
private Action<EnemyBolt> _release;

// "Dispara" um bolt que acabou de sair do pool (faz o papel de construtor, como no Projectile).
public void Launch(Vector2 from, float damage, ITarget shooter, PlanetHealth planet, Action<EnemyBolt> release)
{
_position = from;
_damage = damage;
_shooter = shooter;
_planet = planet;
_release = release;
Place();
}

// Anda em linha reta para o centro; confere o Reflector e o planeta.
private void Update()
{
float deltaTime = Time.deltaTime;
if (deltaTime <= 0f) return; // pausado

Vector2 toCenter = WorldLayout.PlanetCenter - _position;
float distance = toCenter.magnitude;
Vector2 next = _position + toCenter / distance * Mathf.Min(_speed * deltaTime, distance);

// Cruzou o arco do Reflector neste passo? Volta como um tiro azul-claro e este bolt some (Seção 4.2).
var reflector = Reflector.Current;
if (reflector != null && reflector.Intercepts(_position, next))
{
reflector.Reflect(next, _shooter);
Release();
return;
}

_position = next;
if (Vector2.Distance(_position, WorldLayout.PlanetCenter) <= WorldLayout.PlanetRadius)
{
_planet.TakeDamage(_damage); // dano bruto: o PlanetHealth aplica Energy Shield e defesa
Release();
return;
}
Place();
}

// Move a RAIZ para a posição atual, no chão. O IsoView do filho View projeta na tela (Fase 2).
private void Place()
{
transform.position = new Vector3(WorldLayout.SnapToPixel(_position.x), WorldLayout.SnapToPixel(_position.y), 0f);
}

// Devolve ao pool uma vez só (ver Projectile.Release).
private void Release()
{
_shooter = null;
var release = _release;
_release = null;
release?.Invoke(this);
}
}
}
// Caminho: Assets/_Project/Scripts/Enemies/BoltPool.cs
using Armageddon.Combat;
using Armageddon.Planets;
using UnityEngine;
using UnityEngine.Pool;

namespace Armageddon.Enemies
{
// POR QUE: com vários Gunners parados atirando a cada 2,5 s, criar e destruir um objeto por tiro seria o
// mesmo problema dos projéteis dos Satellites (ver ProjectilePool).
// ESTRATÉGIA: o mesmo pool do ProjectilePool, para EnemyBolt. Também guarda o PlanetHealth, para o
// Enemy (que pede o tiro) não precisar repassá-lo.
public sealed class BoltPool : MonoBehaviour
{
[SerializeField] private EnemyBolt _prefab;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private int _prewarm = 16;

private ObjectPool<EnemyBolt> _pool;

// Monta e aquece o pool (mesma estratégia do ProjectilePool).
private void Awake()
{
_pool = new ObjectPool<EnemyBolt>(
createFunc: () => Instantiate(_prefab, transform),
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
collectionCheck: false,
defaultCapacity: _prewarm,
maxSize: 256);

var warm = new EnemyBolt[_prewarm];
for (int i = 0; i < _prewarm; i++) warm[i] = _pool.Get();
for (int i = 0; i < _prewarm; i++) _pool.Release(warm[i]);
}

// O único método público: um Gunner Bolt saindo de "from" com o dano do Gunner.
public void Fire(Vector2 from, float damage, ITarget shooter)
{
_pool.Get().Launch(from, damage, shooter, _planet, _pool.Release);
}
}
}

No Enemy (Fase 4), acrescente os campos novos junto dos outros campos privados:

private const float KnockbackDistance = 1.5f; // empurrão do Reflector (Seção 4.2)
private const float ReflectorCooldown = 1f; // o Reflector fere o mesmo inimigo no máximo 1 vez por segundo

private BoltPool _bolts; // Gunner: por onde sai o tiro
private float _speed; // velocidade desta nave (a da definição × o multiplicador do spawn)
private float _boltTimer; // Gunner: segundos até o próximo tiro
private float _reflectorCooldown; // segundos até o Reflector poder ferir este inimigo de novo

Troque o Spawn por esta versão. Ela recebe o multiplicador de velocidade (usado pelo modo Invasion, Fase 8B; nas Waves é sempre 1) e o BoltPool:

// Chamado pelo EnemyPool. Os multiplicadores são a escalada por Wave (Seção 4.3) ou os Fleet Upgrades
// do Invasion (Fase 8B); 1 = valores da definição.
// Zera TODO o estado: o objeto pode estar sendo reaproveitado de um inimigo que já morreu.
// "internal": só código da assembly Armageddon chama (na prática, o EnemyPool).
internal void Spawn(EnemyDefinition definition, Vector2 position, float hitpointsMultiplier, float damageMultiplier,
float speedMultiplier, PlanetHealth planet, EnemyPool pool, BoltPool bolts)
{
_definition = definition;
_movement = definition.Movement;
_planet = planet;
_pool = pool;
_bolts = bolts;
_radialPosition = position;
_hitpoints = definition.Hitpoints * hitpointsMultiplier;
_damage = definition.Damage * damageMultiplier;
_speed = definition.Speed * speedMultiplier;
_boltTimer = 0.5f; // o primeiro tiro sai meio segundo depois de parar
_reflectorCooldown = 0f;
_age = 0f;
_alive = true;

// Variação visual sorteada no spawn (Seção 6.2). O prefab tem "Random Start Frame" marcado,
// para os inimigos não piscarem todos em sincronia.
var variant = definition.Variants[Random.Range(0, definition.Variants.Count)];
_animator.SetFrames(variant.frames, definition.Fps, true);
_view.SetShadowRadius(definition.Radius); // a sombra no grid tem o tamanho deste tipo (Seção 6.2)

ApplyPosition();
TargetRegistry.Register(this); // a partir de agora os Satellites enxergam este inimigo
}

Troque o Update por esta versão e acrescente o método TouchReflector:

// Anda em direção ao centro (ou, no Gunner, para e atira) e confere o planeta e o Reflector.
private void Update()
{
if (!_alive) return;
float deltaTime = Time.deltaTime;
if (deltaTime <= 0f) return; // pausado

_age += deltaTime;
_reflectorCooldown -= deltaTime;

Vector2 toCenter = WorldLayout.PlanetCenter - _radialPosition;
float distance = toCenter.magnitude;

// Tocou o planeta (Seção 4.4): causa dano e some, sem Shards. O Gunner nunca chega aqui.
if (distance <= WorldLayout.PlanetRadius)
{
_planet.TakeDamage(_damage);
Die(EnemyExit.ReachedPlanet);
return;
}

if (_definition.IsRanged && distance <= _definition.HoldDistance)
{
// Gunner parado (Seção 4.4): não anda mais; atira um Gunner Bolt a cada BoltInterval.
// "+=": a sobra do relógio passa para o próximo tiro (ver Satellite.Tick).
_boltTimer -= deltaTime;
if (_boltTimer <= 0f)
{
_boltTimer += _definition.BoltInterval;
_bolts.Fire(Position, _damage, this);
}
}
else
{
// toCenter / distance = direção (vetor de tamanho 1). O Min impede de "passar do centro" num frame
// longo e, no Gunner, de passar do ponto onde ele para.
float step = _speed * deltaTime;
if (_definition.IsRanged) step = Mathf.Min(step, distance - _definition.HoldDistance);
_radialPosition += toCenter / distance * Mathf.Min(step, distance);
}

ApplyPosition();
if (!_definition.IsRanged) TouchReflector();
}

// Kamikaze encostando no Reflector (Seção 4.2): toma o Reflect Damage e, se sobreviver, é empurrado
// 1,5 u para fora. O cooldown impede o dano a cada frame enquanto ele atravessa o arco.
private void TouchReflector()
{
var reflector = Reflector.Current;
if (reflector == null || _reflectorCooldown > 0f || !reflector.Touches(Position, Radius)) return;

_reflectorCooldown = ReflectorCooldown;
TakeDamage(reflector.Damage, false);
if (!_alive) return; // morreu com o reflexo: já saiu de cena pelo Die

// Empurra a posição "na linha reta" para longe do planeta. O avanço continua no próximo frame.
Vector2 outward = (_radialPosition - WorldLayout.PlanetCenter).normalized;
_radialPosition += outward * KnockbackDistance;
ApplyPosition();
}

No EnemyPool (Fase 4), acrescente o campo do pool de tiros e troque o Spawn:

[SerializeField] private BoltPool _bolts; // Fase 8A: os tiros do Gunner
// Um "slot de spawn" (Seção 4.3): 1 inimigo, ou um cacho de ClusterSize (Swarmer = 8) espalhado em volta do ponto.
// Parâmetros com "= 1f" são opcionais: as Waves não passam velocidade, e as ferramentas de teste não passam nada.
public void Spawn(EnemyDefinition definition, Vector2 position, float hitpointsMultiplier = 1f,
float damageMultiplier = 1f, float speedMultiplier = 1f)
{
int count = Mathf.Max(1, definition.ClusterSize);
for (int i = 0; i < count; i++)
{
// insideUnitCircle = ponto aleatório dentro de um círculo de raio 1.
Vector2 p = count == 1 ? position : position + UnityEngine.Random.insideUnitCircle * definition.ClusterSpread;
AliveCount++;
_pool.Get().Spawn(definition, p, hitpointsMultiplier, damageMultiplier, speedMultiplier, _planet, this, _bolts);
}
}

O Enemy.cs precisa de using Armageddon.Combat; (já tem, por causa do ITarget) para enxergar o Reflector.

Passo 6 — O laser contra o Reflector: LaserAttack e Mothership​

No LaserAttack (Fase 7), acrescente os campos:

private ITarget _owner; // a Mothership: quem toma o Reflect Damage quando o laser é bloqueado
private bool _blocked; // o disparo atual foi bloqueado pelo Reflector
private float _blockRadius; // onde o feixe para quando bloqueado (a órbita do Reflector)

Troque o Init por:

// Chamado pela Mothership ao nascer: para quem vai o dano e de quem é o laser.
public void Init(PlanetHealth planet, ITarget owner)
{
_planet = planet;
_owner = owner;
}

e, no LateUpdate, troque o bloco do fim do aviso (if (_state == State.Telegraph && _time >= _telegraphTime) { ... }) por:

if (_state == State.Telegraph && _time >= _telegraphTime)
{
// Reflector no caminho (Seção 4.2)? O laser vai em linha reta da Mothership ao centro, então
// cruza a órbita do Reflector no MESMO ângulo da Mothership: basta perguntar se ele cobre esse ponto.
var reflector = Reflector.Current;
_blocked = reflector != null && reflector.Covers(transform.position);
if (_blocked)
{
_blockRadius = reflector.OrbitRadius;
_owner.TakeDamage(reflector.Damage, false);
if (_state == State.Idle) return; // o reflexo matou a Mothership: ela já cancelou o laser
}
else
{
_planet.TakeDamage(_damage); // dano bruto: o PlanetHealth aplica Energy Shield e defesa
}

_state = State.Beam;
_time = 0f;
_lineAnimator.SetFrames(_beamFrames, _beamFps, true);
_impact.gameObject.SetActive(true);
_impact.SetFrames(_impactFrames, _beamFps, true);
}

No Place, troque a linha que calcula o end por esta, para o feixe bloqueado parar no Reflector:

// Bloqueado: o feixe (e a faísca) param no Reflector. O ponto de bloqueio é calculado no CHÃO (na
// direção da Mothership, à distância da órbita do Reflector) e projetado na tela, como o resto.
Vector2 groundDirection = ((Vector2)transform.position - WorldLayout.PlanetCenter).normalized;
Vector2 end = _state == State.Beam && _blocked
? IsoProjection.GroundToView(WorldLayout.PlanetCenter + groundDirection * _blockRadius)
: center - direction * WorldLayout.PlanetRadius;

O LaserAttack.cs precisa de using Armageddon.Combat; no topo. Na Mothership (Fase 7), dentro do Spawn, troque _laser.Init(planet); por:

_laser.Init(planet, this);

Passo 7 — Aplicar os Stats novos: StatApplier e o RunEconomy​

O modo Invasion (Fase 8B) também vai precisar de "copiar os Stats para os Satellites e o planeta", com outra economia. Por isso o ApplyAll do RunEconomy sai para uma classe própria:

// Caminho: Assets/_Project/Scripts/Economy/StatApplier.cs
using Armageddon.Combat;
using Armageddon.Planets;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: "pegar os valores atuais dos Stats e empurrar para quem usa" é feito pelo RunEconomy (Last
// Stand) e pelo PlanetAI (Invasion, Fase 8B). Duas cópias da mesma lista de 17 Stats sairiam do passo.
// ESTRATÉGIA: classe estática com uma função só, que recebe os Stats e os 4 destinos.
public static class StatApplier
{
// Copia os valores para os Satellites (via um SatelliteStats novo, ver a nota da Fase 6), o planeta,
// o Energy Shield e o Reflector.
public static void Apply(RunStats stats, SatelliteOrbit orbit, PlanetHealth planet, EnergyShield shield, Reflector reflector)
{
orbit.SetStats(new SatelliteStats
{
damage = stats.Value(StatId.Damage),
attackSpeed = stats.Value(StatId.AttackSpeed),
criticalChance = stats.Value(StatId.CriticalChance),
criticalFactor = stats.Value(StatId.CriticalFactor),
attackRange = stats.Value(StatId.AttackRange),
impetus = stats.Value(StatId.Impetus),
orbitSpeed = stats.Value(StatId.OrbitSpeed),
multishotChance = stats.Value(StatId.MultishotChance),
multishotTargets = Mathf.RoundToInt(stats.Value(StatId.MultishotTargets)),
});
planet.SetMaxHitpoints(stats.Value(StatId.Hitpoints));
planet.SetRegeneration(stats.Value(StatId.Regeneration));
planet.SetDefense(stats.Value(StatId.DefenseAbsolute), stats.Value(StatId.DefenseRelative));
shield.SetMaxCharges(Mathf.RoundToInt(stats.Value(StatId.EnergyShield)));
reflector.SetDamage(stats.Value(StatId.ReflectShield));
}
}
}

Substitua o RunEconomy (versão da Fase 7) por esta. Ela só acrescenta os campos Shield e Reflector e troca o ApplyAll pelo StatApplier:

// Caminho: Assets/_Project/Scripts/Economy/RunEconomy.cs
using System.Collections.Generic;
using Armageddon.Combat;
using Armageddon.Enemies;
using Armageddon.Planets;
using Armageddon.Waves;
using UnityEngine;

namespace Armageddon.Economy
{
// POR QUE: RunStats, ShardWallet, UpgradeService e SatelliteShop são classes puras: alguém precisa
// criá-las a cada run e ligá-las ao resto do jogo (inimigos dão Shards, Wave Clear dá Shards, Stats
// mudam Satellites, planeta, Energy Shield e Reflector). Sem um "ligador", esses sistemas teriam que se conhecer.
// ESTRATÉGIA: composição — um MonoBehaviour na cena Gameplay que (1) cria as quatro classes no Awake,
// (2) escuta os eventos do EnemyPool, do MothershipSpawner, do WaveDirector e do RunStats, e (3) empurra
// os valores novos para quem usa, pelo StatApplier. A UI lê Stats/Wallet/Upgrades/Satellites pelas
// propriedades públicas.
public sealed class RunEconomy : MonoBehaviour
{
[SerializeField] private StatCatalog _catalog;
[SerializeField] private WaveBalance _waveBalance;
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private EnergyShield _shield; // NOVO na Fase 8A
[SerializeField] private Reflector _reflector; // NOVO na Fase 8A
[SerializeField] private EnemyPool _enemies;
[SerializeField] private MothershipSpawner _motherships;
[SerializeField] private WaveDirector _waves;
[SerializeField] private int _startingShards = 15; // saldo no início de toda run (Seção 4.2)
[SerializeField] private int[] _satelliteCosts = { 10, 50, 100 }; // 2º, 3º e 4º Satellite (Seção 4.2)

public StatCatalog Catalog => _catalog;
public RunStats Stats { get; private set; }
public ShardWallet Wallet { get; private set; }
public UpgradeService Upgrades { get; private set; }
public SatelliteShop Satellites { get; private set; }

// Cria a economia da run. No Awake porque outros scripts (a gaveta) leem Stats/Wallet no Start deles.
private void Awake()
{
Stats = new RunStats(_catalog.Stats);
Wallet = new ShardWallet();
Upgrades = new UpgradeService(Stats, Wallet);
Satellites = new SatelliteShop(_orbit, Wallet, _satelliteCosts);
}

// Liga os eventos (OnEnable roda depois do Awake, então Stats já existe).
private void OnEnable()
{
Stats.StatChanged += HandleStatChanged;
_enemies.EnemyKilled += HandleEnemyKilled;
_motherships.MothershipKilled += HandleMothershipKilled;
_waves.WaveCleared += HandleWaveCleared;
}

private void OnDisable()
{
Stats.StatChanged -= HandleStatChanged;
_enemies.EnemyKilled -= HandleEnemyKilled;
_motherships.MothershipKilled -= HandleMothershipKilled;
_waves.WaveCleared -= HandleWaveCleared;
}

// Saldo inicial e primeira aplicação dos Stats (substitui os valores provisórios do Inspector).
private void Start()
{
Wallet.Add(_startingShards);
ApplyAll();
}

// O Planet Core (Fase 9) chama isto no começo da run.
public void AddModifier(StatModifier modifier)
{
Stats.AddModifier(modifier);
}

// Qualquer Stat mudou (compra ou modificador): reaplica todos.
private void HandleStatChanged(StatId id)
{
ApplyAll(); // são só 17 números: recalcular tudo é mais simples e à prova de esquecimento
}

// Copia os valores atuais dos Stats para quem os usa (ver StatApplier).
private void ApplyAll()
{
StatApplier.Apply(Stats, _orbit, _planet, _shield, _reflector);
}

// Inimigo comum destruído: Shards da definição (Seção 4.4).
private void HandleEnemyKilled(Enemy enemy)
{
AwardKill(enemy.Definition.Shards);
}

// Mothership destruída: 50 Shards (Seção 4.5).
private void HandleMothershipKilled(Mothership mothership)
{
AwardKill(mothership.Shards);
}

// Shards por kill. O Resource Bonus é a chance de a kill pagar o dobro (Seção 4.4).
private void AwardKill(float baseShards)
{
// UnityEngine.Random.value = número aleatório entre 0 e 1.
bool doubled = UnityEngine.Random.value < Stats.Value(StatId.ResourceBonus);
Wallet.Add(doubled ? baseShards * 2f : baseShards);
}

// Bônus de Wave Clear por Wave paga: (5 + w) × (1 + Resource per Wave) (Seção 4.3).
private void HandleWaveCleared(IReadOnlyList<int> paidWaves, bool beforeTimer)
{
float multiplier = 1f + Stats.Value(StatId.ResourcePerWave);
foreach (int wave in paidWaves) Wallet.Add(_waveBalance.ClearBonus(wave) * multiplier);
}
}
}

Passo 8 — Os assets novos​

  1. Import da arte (preset da Fase 0; Sprite Mode Multiple, Grid By Cell Size do tamanho da tabela da Seção 6.4): Enemies/SPR_Enemy_Gunner (32×32, 4 frames), Projectiles/SPR_Projectile_GunnerBolt e SPR_Projectile_Reflected (32×32, 2 frames cada), Satellites/SPR_Reflector (32×32, 2 frames), VFX/SPR_VFX_EnergyShield (48×48, 3 linhas × 4) e VFX/SPR_VFX_EnergyShield_Pop (48×48, 5 frames). O UI/SPR_UI_Icons ganhou 2 linhas (agora 8 × 5): abra o Sprite Editor e fatie de novo com Grid By Cell Size 32×32. As células antigas não mudaram de lugar.
  2. Os 4 Stats, em Data/Stats/, como na Fase 6:
AssetIdTrackDisplay NameBasePer LevelCapBase CostFormatIcon (célula)Casos especiais
Stat_MultishotChanceMultishotChanceOffenseMultishot00.020.510Percent22—
Stat_MultishotTargetsMultishotTargetsOffenseMulti Targets11425Number23—
Stat_EnergyShieldEnergyShieldDefenseShield0130Number24Fixed Costs = 30, 90, 250; Whole Number marcado
Stat_ReflectShieldReflectShieldDefenseReflect08015Number25First Level Bonus = 7 (nível 1 = 0 + 8 + 7 = 15)

No StatCatalog, arraste os dois de Offense depois do Stat_OrbitSpeed e os dois de Defense depois do Stat_DefenseRelative: é a ordem dos cards na gaveta. 3. O Gunner, em Data/Enemies/, como na Fase 4: Enemy_Gunner com Hitpoints 14, Damage 4, Speed 0.9, Shards 2, Radius 0.5, Movement Straight, Unlock Wave 5, Spawn Weight 12, Cluster Size 1, Variants = 1 variação com as 4 células de SPR_Enemy_Gunner, Fps 6, Explosion Small, Hold Distance 4.5, Bolt Interval 2.5. Arraste o asset para a lista Enemies do WaveBalance (Fase 5): é só isso que o coloca nas Waves.

Passo 9 — Montar na cena​

  1. Prefab do Gunner Bolt: objeto vazio EnemyBolt com o componente Enemy Bolt. Dentro dele, um filho vazio View com Sprite Renderer (Sorting Layer World), Sprite Animator (Frames = as 2 células de SPR_Projectile_GunnerBolt, Fps 8, Loop marcado) e Iso View (Shadow Radius 0, como os projéteis). Arraste para Prefabs/Gameplay/ e apague da cena.
  2. Prefab do tiro refletido: duplique o prefab Projectile da Fase 3 (Ctrl+D na janela Project) com o nome ProjectileReflected. No Sprite Animator do View dele, troque os frames pelas 2 células de SPR_Projectile_Reflected; no Projectile, troque as 3 Trail Colors por tons de azul-claro (ex.: #8FD8FF com alfa 200, #5AA8E0 com 140, #3A78B0 com 90).
  3. Em [Systems]:
    • BoltPool, com o componente BoltPool: Prefab = EnemyBolt; Planet = o PlanetHealth.
    • ReflectedShots, com o componente ProjectilePool: Prefab = ProjectileReflected; Prewarm 8.
    • No EnemyPool, Bolts = BoltPool.
    • No ExplosionPool, acrescente um item em Explosions: ShieldPop, com as 5 células de SPR_VFX_EnergyShield_Pop e Fps 12.
  4. O Energy Shield: no objeto Planet, Add Component → Energy Shield (Recharge Seconds 12). No PlanetHealth, Shield = esse componente. Crie um filho do View do Planet chamado ShieldView, na posição local (0, 0, 0) (assim a bolha fica em volta do desenho do planeta, no plano de voo), com Sprite Renderer (Sorting Layer VFX), Sprite Animator e Energy Shield View: Shield = o EnergyShield; Explosions = o ExplosionPool; Rows com 3 itens (cargas 1, 2 e 3): as 4 células de cada linha de SPR_VFX_EnergyShield, de cima para baixo; Fps 6.
  5. O Reflector: em [World], crie Reflector com o componente Reflector (Reflected Shots = ReflectedShots). Dentro dele, um filho vazio View (ligue no campo Visual) com Sprite Renderer (Sorting Layer World), Sprite Animator (as 2 células de SPR_Reflector, Fps 2, Loop) e Iso View (Shadow Radius 0.25, como o Satellite). Dentro do View, um filho Arc com o componente Reflector Arc View (Reflector = o Reflector). O Arc fica dentro do View só para sumir junto com ele: os pixels do arco calculam a própria posição na tela.
  6. No RunEconomy: Shield = o EnergyShield do Planet; Reflector = o Reflector.

Passo 10 — Commit​

git add . e git commit -m "Phase 8A: multishot, energy shield, reflect shield, gunner".

✅ Checkpoint:

  • A aba Offense tem 9 cards (Multishot e Multi Targets depois de Orbit Speed) e a Defense tem 6 (Shield e Reflect no fim). O card do Shield mostra 0 > 1 e custo 30; depois de 3 compras, MAX. O do Reflect mostra 0 > 15, depois 15 > 23.
  • Com M (Shards de teste) e algumas compras de Multishot, alguns disparos soltam 2 ou mais projéteis de uma vez, sempre em inimigos diferentes do mesmo Quadrant.
  • Comprando Shield: a bolha aparece em volta do planeta com 1 anel. Um inimigo que bate no planeta some sem tirar HP e a bolha some, com o estouro. 12 s depois, ela volta. Com 3 níveis, 3 anéis e 3 golpes anulados.
  • Comprando Reflect: o Reflector (o prisma azul com o arco) aparece embaixo e à direita do planeta (o ângulo de 270° do chão, na vista isométrica) e gira no sentido horário, a 30°/s (uma volta a cada 12 s). Um kamikaze que encosta no arco toma 15 de dano e, se sobreviver, é empurrado para longe.
  • Na Wave 5 (use as teclas da Fase 5 para avançar), aparecem Gunners: eles param a 4,5 u do centro e atiram bolinhas magenta a cada 2,5 s. Um Bolt que passa pelo arco do Reflector volta azul-claro e acerta o Gunner que atirou.
  • Na Boss Wave, um laser disparado com o Reflector no caminho para no arco e tira o Reflect Damage da Mothership (a barra de HP dela desce um pouco), e o planeta não perde HP.

Problemas comuns:

  • Depois de mudar o StatId, um Stat antigo virou outro (ex.: o card de Damage mostra Multishot): os Stats novos foram inseridos no meio do enum. Eles precisam ficar no fim, como no Passo 1.
  • O card do Energy Shield continua comprável depois do nível 3: o Fixed Costs do asset está vazio, então valem a fórmula e o teto. Preencha os 3 preços.
  • O Reflector nunca aparece: o campo Reflector do RunEconomy está vazio (o StatApplier dá erro no Console) ou o Visual do Reflector não foi ligado.
  • O arco aparece num lugar e o prisma noutro: o View do Reflector está sem o Iso View, então o prisma fica na posição do chão sem projeção.
  • Os Gunners passam direto e batem no planeta: o Hold Distance do Enemy_Gunner ficou 0.
  • NullReferenceException no Enemy.Update quando um Gunner para: o campo Bolts do EnemyPool está vazio.
  • O Bolt refletido some no caminho: o Gunner morreu antes; o tiro vai para o inimigo vivo mais perto, e some se não houver ninguém (Seção 4.2).

Próxima fase: Fase 8B — Modo Invasion.


Fase 8B — Modo Invasion​

Objetivo desta fase: o modo Invasion (Seção 4.7) jogável numa cena própria. O planeta no centro é defendido por uma IA, com as regras de sempre (Satellites, Stats, Energy Shield, Reflector), e o jogador é a invasão: escolhe um Spawn Sector tocando no espaço, lança naves pela aba Deploy pagando Plasma e compra Fleet Upgrades. Cada planeta tem um Siege Timer de 120 s; derrubar o planeta dá Plasma e traz o próximo, mais forte; o timer acabar encerra a run. A escolha do modo no menu entra com a Core Select, na Fase 9; até lá, a cena Invasion é aberta direto no Editor.

Conceitos novos:

  • Reaproveitar sem copiar: quase nada do combate é novo. O planeta, a órbita, os inimigos, os pools, o Reflector e o Energy Shield são os mesmos objetos da cena Gameplay. O que muda é quem decide: em vez do WaveDirector (que cria inimigos) e do RunEconomy (que recebe as compras do jogador), entram o FleetLauncher (o jogador cria os inimigos) e o PlanetAI (a IA compra os Upgrades do planeta).
  • Uma IA de compras simples: a IA não pensa. Ela percorre uma lista fixa de Stats (o perfil do Planet Core) e compra o próximo item quando cabe no saldo; se não cabe, junta Shards. É previsível de propósito: o jogador aprende a ler o perfil.
  • O mesmo objeto, planetas diferentes: derrubar um planeta não destrói nada. O PlanetAI monta o próximo Target Planet no mesmo objeto Planet: Stats novos, Satellites novos, HP cheio, outro sprite.
  • Interface para telas compartilhadas: a tela de pausa da Fase 8 chamava o RunController. Agora ela fala com uma interface, IRunFlow ("a run está rodando?" e "desistir"), que o RunController e o SiegeController cumprem. Assim a mesma tela serve aos dois modos.

Passo 1 — Pequenas mudanças em classes antigas​

No SceneLoader (Fase 1), acrescente a cena nova no fim do enum (os nomes precisam ser iguais aos arquivos de cena):

public enum GameScene { Boot, MainMenu, Gameplay, Invasion }

No PlanetHealth (Fase 2), acrescente:

// HP cheio e "vivo" de novo. O Invasion chama a cada Target Planet novo (Fase 8B): o mesmo objeto
// Planet vira o próximo planeta.
public void RestoreFull()
{
IsDead = false;
Current = _maxHitpoints;
HealthChanged?.Invoke();
}

Na Mothership (Fase 7), o Spawn ganha um último parâmetro opcional, de onde ela começa a descer. Nas Boss Waves ela continua nascendo na borda (12 u); no Invasion, nasce a 9 u, dentro da tela. Troque a assinatura e a linha do _distance:

public void Spawn(int wave, float angleDegrees, float hitpointsMultiplier, float damageMultiplier,
PlanetHealth planet, EnemyPool enemies, float startDistance = WorldLayout.SpawnRadius)
_distance = startDistance; // antes: WorldLayout.SpawnRadius

No SpawnSectorIndicator (Fase 5), troque o OnEnable e o OnDisable por estes e acrescente o ShowSelection. Com o campo Waves vazio, a mesma peça vira o seletor de setor do Invasion:

// Inscreve nos eventos do WaveDirector (e desinscreve no OnDisable). Sem WaveDirector (cena Invasion),
// não escuta nada: quem acende as setas é o ShowSelection.
private void OnEnable()
{
if (_waves == null) return;
_waves.WaveWarning += HandleWaveWarning;
_waves.WaveStarted += HandleWaveStarted;
}

private void OnDisable()
{
if (_waves == null) return;
_waves.WaveWarning -= HandleWaveWarning;
_waves.WaveStarted -= HandleWaveStarted;
}

// Modo seleção (Invasion, Seção 4.7): as 6 setas ficam na tela o tempo todo; a do setor escolhido
// em cor cheia, as outras a 35%. O SectorPicker chama sempre que o setor muda.
public void ShowSelection(int selected)
{
for (int i = 0; i < _views.Length; i++)
{
_views[i].gameObject.SetActive(true);
_views[i].GetComponent<SpriteRenderer>().color = i == selected ? Color.white : new Color(1f, 1f, 1f, 0.35f);
}
}

A interface da pausa:

// Caminho: Assets/_Project/Scripts/Run/IRunFlow.cs
namespace Armageddon.Run
{
// POR QUE: a tela de pausa precisa de duas coisas da run: saber se ela está rodando e mandar desistir.
// No Last Stand quem responde é o RunController; no Invasion, o SiegeController.
// ESTRATÉGIA: uma interface com só essas duas coisas. A PauseView fala com ela e não sabe qual modo é.
public interface IRunFlow
{
bool IsRunning { get; }
void GiveUp();
}
}

No RunController (Fase 8), troque a linha da classe e acrescente a propriedade:

public sealed class RunController : MonoBehaviour, IRunFlow
public bool IsRunning => State == RunState.Running; // IRunFlow (a PauseView usa)

Na PauseView (Fase 8), troque o campo _run e acrescente o _flow:

// RunController ou SiegeController. O Inspector não mostra campos de interface; por isso o campo é
// um MonoBehaviour, convertido para IRunFlow no Awake.
[SerializeField] private MonoBehaviour _run;
private IRunFlow _flow;

No Awake dela, acrescente _flow = (IRunFlow)_run; como primeira linha e troque _run.GiveUp() por _flow.GiveUp(). No LateUpdate, troque _run.State == RunController.RunState.Running por _flow.IsRunning.

Mudar o tipo do campo de RunController para MonoBehaviour não perde a referência já ligada na cena Gameplay: o RunController também é um MonoBehaviour. Confira no Inspector da PauseView que o campo Run continua preenchido.

Passo 2 — Os dados do modo: AiProfile e InvasionBalance​

// Caminho: Assets/_Project/Scripts/Invasion/AiProfile.cs
using System.Collections.Generic;
using Armageddon.Economy;
using UnityEngine;

namespace Armageddon.Invasion
{
// POR QUE: no Invasion, cada Target Planet sorteia um Planet Core e um jeito de gastar que combina com
// ele (Seção 4.7): Terra equilibrado, Ice compra defesa, Magma compra ataque. Isso é dado, não código.
// ESTRATÉGIA: ScriptableObject com o visual do planeta, os modificadores do Core (os da Seção 5.2,
// como StatModifier, o mesmo tipo que o RunStats já entende) e a ordem de compra que a IA percorre em
// círculo. Quando a Fase 9 criar o PlanetCoreDefinition, os modificadores podem passar a vir de lá.
[CreateAssetMenu(menuName = "Armageddon/AI Profile", fileName = "AiProfile_")]
public sealed class AiProfile : ScriptableObject
{
[SerializeField] private string _displayName = "Terra Core";
[SerializeField] private Sprite[] _planetFrames; // a rotação do planeta (SPR_Planet_<Core>_Classic_Rotate)
[SerializeField] private List<StatModifier> _modifiers = new List<StatModifier>();
[SerializeField] private List<StatId> _purchaseOrder = new List<StatId>(); // pode repetir um Stat

public string DisplayName => _displayName;
public Sprite[] PlanetFrames => _planetFrames;
public IReadOnlyList<StatModifier> Modifiers => _modifiers;
public IReadOnlyList<StatId> PurchaseOrder => _purchaseOrder;
}
}
// Caminho: Assets/_Project/Scripts/Invasion/InvasionBalance.cs
using System;
using System.Collections.Generic;
using Armageddon.Enemies;
using UnityEngine;

namespace Armageddon.Invasion
{
// POR QUE: cada nave da aba Deploy tem custo e planeta de liberação (Seção 4.7). A nave em si (HP,
// dano, velocidade, sprite) já está na EnemyDefinition da Fase 4: aqui só entra o que é do Invasion.
// ESTRATÉGIA: pacote [Serializable] editado dentro do InvasionBalance. Enemy vazio = a Mothership,
// que não é uma EnemyDefinition (Fase 7).
[Serializable]
public sealed class ShipOffer
{
public string displayName = "Grunt";
public Sprite icon; // o primeiro frame do sprite da nave
public EnemyDefinition enemy; // vazio = Mothership
public int cost = 4; // em Plasma
public int unlockPlanet = 1; // a partir de qual Target Planet aparece liberada

public bool IsMothership => enemy == null;
}

// Os 4 Fleet Upgrades (Seção 4.7). Novos entram no fim (ver o StatId da Fase 8A).
public enum FleetStat { Hull, Warheads, Thrusters, PlasmaFlow }

// POR QUE: os Fleet Upgrades seguem a regra dos Stats (incremento por nível, teto, custo × 1,15^nível),
// mas são outra lista, comprada com Plasma e válida só no Invasion.
// ESTRATÉGIA: pacote [Serializable] com os números de um Fleet Upgrade e o jeito de mostrar o valor.
[Serializable]
public sealed class FleetUpgradeDefinition
{
public FleetStat stat;
public string displayName = "Hull";
public Sprite icon;
public float perLevel = 0.1f; // +10% por nível; no Plasma Flow, +0,5 Plasma/s
public float cap; // 0 = sem teto (Thrusters: 0,5 = +50%)
public int baseCost = 15;
public bool isPercent = true; // false = mostra como "/s" (Plasma Flow)

// "+30%" ou "+1.5/s", para o card.
public string Format(float value) => isPercent ? $"+{value * 100f:0}%" : $"+{value:0.#}/s";
}

// POR QUE: todos os números do Invasion (Seção 4.7) num lugar só, para balancear sem mexer em código,
// como o WaveBalance faz no Last Stand.
// ESTRATÉGIA: ScriptableObject com os números e as fórmulas pequenas (Shards da IA, Satellites, prêmio).
[CreateAssetMenu(menuName = "Armageddon/Invasion Balance", fileName = "InvasionBalance")]
public sealed class InvasionBalance : ScriptableObject
{
[Header("Cerco")]
[SerializeField] private float _siegeSeconds = 120f;
[SerializeField] private int _fortressEvery = 5; // todo 5º planeta é uma Fortress
[SerializeField] private int _fortressReflectLevel = 3;

[Header("Plasma do jogador")]
[SerializeField] private int _startingPlasma = 20;
[SerializeField] private float _plasmaPerSecond = 3f;
[SerializeField] private float _plasmaPerHit = 1f;
[SerializeField] private int _rewardPerPlanetNumber = 10; // planeta n dá 10 × n (o dobro na Fortress)

[Header("Lançamento")]
[SerializeField] private float _deployRadius = 9f; // as naves nascem dentro da tela
[SerializeField] private float _sectorMargin = 8f; // graus de folga nas bordas do setor

[Header("IA do planeta")]
[SerializeField] private float _aiStartingShards = 20f; // Shards do planeta 1
[SerializeField] private float _aiShardGrowth = 1.35f; // × 1,35 a cada planeta
[SerializeField] private float _aiBonusInterval = 30f; // a cada 30 s, a IA ganha 5 + n Shards
[SerializeField] private float _aiSpendInterval = 0.5f; // de quanto em quanto tempo a IA tenta comprar

[Header("Listas")]
[SerializeField] private List<ShipOffer> _ships = new List<ShipOffer>();
[SerializeField] private List<FleetUpgradeDefinition> _fleetUpgrades = new List<FleetUpgradeDefinition>();
[SerializeField] private List<AiProfile> _profiles = new List<AiProfile>();

public float SiegeSeconds => _siegeSeconds;
public int StartingPlasma => _startingPlasma;
public float PlasmaPerSecond => _plasmaPerSecond;
public float PlasmaPerHit => _plasmaPerHit;
public float DeployRadius => _deployRadius;
public float SectorMargin => _sectorMargin;
public int FortressReflectLevel => _fortressReflectLevel;
public float AiBonusInterval => _aiBonusInterval;
public float AiSpendInterval => _aiSpendInterval;
public IReadOnlyList<ShipOffer> Ships => _ships;
public IReadOnlyList<FleetUpgradeDefinition> FleetUpgrades => _fleetUpgrades;
public IReadOnlyList<AiProfile> Profiles => _profiles;

public bool IsFortress(int planet) => _fortressEvery > 0 && planet % _fortressEvery == 0;

// Satellites do planeta n: min(1 + floor((n − 1) / 2), 4); a Fortress começa com 4 (Seção 4.7).
public int SatelliteCount(int planet) => IsFortress(planet) ? 4 : Mathf.Min(1 + (planet - 1) / 2, 4);

// Shards iniciais da IA no planeta n: 20 × 1,35^(n−1).
public float StartingShards(int planet) => _aiStartingShards * Mathf.Pow(_aiShardGrowth, planet - 1);

// Bônus periódico da IA no planeta n: 5 + n.
public float AiBonus(int planet) => 5f + planet;

// Plasma por planeta destruído: 10 × n, o dobro na Fortress.
public int PlanetReward(int planet) => _rewardPerPlanetNumber * planet * (IsFortress(planet) ? 2 : 1);
}
}

Divisão de inteiros: em (planet - 1) / 2, os dois lados são int, então o C# já descarta a parte decimal (3 / 2 = 1). É o floor da fórmula do GDD sem precisar de Mathf.Floor.

Passo 3 — Os Fleet Upgrades: FleetUpgrades​

// Caminho: Assets/_Project/Scripts/Invasion/FleetUpgrades.cs
using System;
using System.Collections.Generic;
using Armageddon.Economy;
using UnityEngine;

namespace Armageddon.Invasion
{
// POR QUE: os Fleet Upgrades têm nível, valor e custo durante a run, como o RunStats, mas são outra
// lista, pagos com Plasma (Seção 4.7).
// ESTRATÉGIA: classe C# pura (como o RunStats), criada pelo SiegeController a cada run. A carteira de
// Plasma é um ShardWallet: a classe já faz o que é preciso (soma frações, gasta inteiros, avisa por evento).
public sealed class FleetUpgrades
{
private readonly Dictionary<FleetStat, FleetUpgradeDefinition> _definitions = new Dictionary<FleetStat, FleetUpgradeDefinition>();
private readonly Dictionary<FleetStat, int> _levels = new Dictionary<FleetStat, int>();

public event Action<FleetStat> Changed;

// Monta as tabelas a partir das definições do InvasionBalance, todos os níveis em 0.
public FleetUpgrades(IEnumerable<FleetUpgradeDefinition> definitions)
{
foreach (var definition in definitions)
{
_definitions[definition.stat] = definition;
_levels[definition.stat] = 0;
}
}

public FleetUpgradeDefinition Definition(FleetStat stat) => _definitions[stat];
public int Level(FleetStat stat) => _levels.TryGetValue(stat, out int level) ? level : 0;
public float Value(FleetStat stat) => ValueAtLevel(stat, Level(stat));

// Multiplicador para Hull, Warheads e Thrusters: +10% vira × 1,1.
public float Multiplier(FleetStat stat) => 1f + Value(stat);

// Incremento × nível, limitado pelo teto. Um Fleet Upgrade que não está na lista vale 0.
public float ValueAtLevel(FleetStat stat, int level)
{
if (!_definitions.TryGetValue(stat, out var definition)) return 0f;
float value = definition.perLevel * level;
return definition.cap > 0f ? Mathf.Min(value, definition.cap) : value;
}

// No teto quando o próximo nível não muda mais o valor.
public bool IsMaxed(FleetStat stat)
{
var definition = _definitions[stat];
return definition.cap > 0f && definition.perLevel * Level(stat) >= definition.cap - 0.0001f;
}

// Mesma fórmula dos Stats (Seção 4.2): baseCost × 1,15^nível.
public int NextCost(FleetStat stat)
{
return Mathf.RoundToInt(_definitions[stat].baseCost * Mathf.Pow(StatDefinition.CostGrowth, Level(stat)));
}

public bool CanPurchase(FleetStat stat, ShardWallet plasma) => !IsMaxed(stat) && plasma.Balance >= NextCost(stat);

// Compra completa ou nada (ver UpgradeService.TryPurchase).
public bool TryPurchase(FleetStat stat, ShardWallet plasma)
{
if (!CanPurchase(stat, plasma)) return false;
plasma.TrySpend(NextCost(stat));
_levels[stat]++;
Changed?.Invoke(stat);
return true;
}
}
}

Passo 4 — A IA do planeta: PlanetAI​

// Caminho: Assets/_Project/Scripts/Invasion/PlanetAI.cs
using System;
using Armageddon.Combat;
using Armageddon.Economy;
using Armageddon.Enemies;
using Armageddon.Planets;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Invasion
{
// POR QUE: no Invasion, quem compra os Upgrades do planeta é uma IA (Seção 4.7). Ela usa exatamente as
// regras do jogador no Last Stand: RunStats, Shards, os mesmos custos e o StatApplier.
// ESTRATÉGIA: o "RunEconomy da IA". SetupPlanet monta cada Target Planet do zero (perfil sorteado,
// Stats novos com os modificadores do Core, Shards iniciais, Satellites, HP cheio). Depois, a cada meio
// segundo, a IA tenta comprar o próximo item do perfil; se não cabe, espera juntar. Ela ganha Shards
// pelas naves que destrói (como o jogador ganharia) e por um bônus periódico.
public sealed class PlanetAI : MonoBehaviour
{
[SerializeField] private InvasionBalance _balance;
[SerializeField] private StatCatalog _catalog;
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private EnergyShield _shield;
[SerializeField] private Reflector _reflector;
[SerializeField] private SpriteAnimator _planetAnimator; // o sprite do planeta (troca a cada Core)
[SerializeField] private float _planetFps = 4f;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private FleetLauncher _launcher;

private UpgradeService _upgrades;
private int _orderIndex; // posição atual na lista de compras do perfil
private float _spendTimer;
private float _bonusTimer;

public int PlanetNumber { get; private set; }
public AiProfile Profile { get; private set; }
public RunStats Stats { get; private set; }
public ShardWallet Wallet { get; private set; }

public event Action PlanetReady; // um Target Planet novo acabou de ser montado (HUD e painel de leitura)

private void OnEnable()
{
_enemies.EnemyKilled += HandleEnemyKilled;
_launcher.MothershipDestroyed += HandleMothershipDestroyed;
}

private void OnDisable()
{
_enemies.EnemyKilled -= HandleEnemyKilled;
_launcher.MothershipDestroyed -= HandleMothershipDestroyed;
if (Stats != null) Stats.StatChanged -= HandleStatChanged;
}

// Monta o Target Planet "number" no mesmo objeto Planet (Seção 4.7). Quem chama é o SiegeController.
public void SetupPlanet(int number)
{
if (Stats != null) Stats.StatChanged -= HandleStatChanged; // os Stats do planeta anterior não valem mais

PlanetNumber = number;
Profile = _balance.Profiles[UnityEngine.Random.Range(0, _balance.Profiles.Count)];
_planetAnimator.SetFrames(Profile.PlanetFrames, _planetFps, true);

Stats = new RunStats(_catalog.Stats);
foreach (var modifier in Profile.Modifiers) Stats.AddModifier(modifier);
Wallet = new ShardWallet();
Wallet.Add(_balance.StartingShards(number));
_upgrades = new UpgradeService(Stats, Wallet);
_orderIndex = 0;
_spendTimer = 0f;
_bonusTimer = 0f;

_orbit.SetCount(_balance.SatelliteCount(number));
// Fortress: Reflect Shield de graça no nível 3. IncreaseLevel é "internal": dá para chamar porque
// todo o jogo está na mesma assembly (Fase 0); quem está fora dela não consegue.
if (_balance.IsFortress(number))
{
for (int i = 0; i < _balance.FortressReflectLevel; i++) Stats.IncreaseLevel(StatId.ReflectShield);
}

Spend(); // os Shards iniciais são gastos na hora (Seção 4.7)
StatApplier.Apply(Stats, _orbit, _planet, _shield, _reflector);
Stats.StatChanged += HandleStatChanged; // daqui em diante, cada compra reaplica na hora

_planet.RestoreFull();
_shield.Refill();
PlanetReady?.Invoke();
}

// Relógios da IA: tentar comprar a cada _aiSpendInterval; bônus de Shards a cada _aiBonusInterval.
private void Update()
{
if (Stats == null) return;
float deltaTime = Time.deltaTime;
if (deltaTime <= 0f) return; // pausado

_bonusTimer += deltaTime;
if (_bonusTimer >= _balance.AiBonusInterval)
{
_bonusTimer -= _balance.AiBonusInterval;
Wallet.Add(_balance.AiBonus(PlanetNumber));
}

_spendTimer += deltaTime;
if (_spendTimer < _balance.AiSpendInterval) return;
_spendTimer = 0f;
Spend();
}

// Percorre a lista do perfil em círculo: pula o que está no máximo, compra o que cabe e para no
// primeiro item que não cabe (junta Shards para ele). O "guard" evita um laço infinito se todos os
// itens estiverem no máximo.
private void Spend()
{
var order = Profile.PurchaseOrder;
if (order.Count == 0) return;

for (int guard = 0; guard < order.Count * 4; guard++)
{
StatId id = order[_orderIndex % order.Count];
if (Stats.IsMaxed(id))
{
_orderIndex++;
continue;
}
if (!_upgrades.TryPurchase(id)) return;
_orderIndex++;
}
}

// Uma compra mudou um Stat: reaplica todos (ver RunEconomy.HandleStatChanged).
private void HandleStatChanged(StatId id)
{
StatApplier.Apply(Stats, _orbit, _planet, _shield, _reflector);
}

// A IA ganha Shards como o jogador ganharia no Last Stand (Seção 4.7): os da nave, com a chance
// de dobro do Resource Bonus.
private void HandleEnemyKilled(Enemy enemy)
{
AwardKill(enemy.Definition.Shards);
}

private void HandleMothershipDestroyed(Mothership mothership)
{
AwardKill(mothership.Shards);
}

// Mesma regra do RunEconomy: o Resource Bonus é a chance de a kill pagar o dobro (Seção 4.4).
private void AwardKill(float baseShards)
{
bool doubled = UnityEngine.Random.value < Stats.Value(StatId.ResourceBonus);
Wallet.Add(doubled ? baseShards * 2f : baseShards);
}
}
}

Passo 5 — O cerco: InvasionSummary e SiegeController​

// Caminho: Assets/_Project/Scripts/Invasion/InvasionSummary.cs
using System;

namespace Armageddon.Invasion
{
// POR QUE: o Results do Invasion mostra outros números que o do Last Stand (Seção 4.7).
// ESTRATÉGIA: pacote de dados preenchido no fim da run (ver RunSummary, Fase 8). A Fase 9 usa o
// planetsDestroyed para o recorde Best Planet.
[Serializable]
public sealed class InvasionSummary
{
public int planetsDestroyed;
public int shipsLaunched;
public float seconds;
public bool gaveUp;
}
}
// Caminho: Assets/_Project/Scripts/Invasion/SiegeController.cs
using System;
using Armageddon.Core;
using Armageddon.Economy;
using Armageddon.Planets;
using Armageddon.Run;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Invasion
{
// POR QUE: o Invasion tem o próprio ciclo de run (Seção 4.7): Plasma entrando, Siege Timer correndo,
// planeta caindo e o próximo chegando, e o fim quando o timer acaba. É o papel do RunController + o do
// WaveDirector no Last Stand.
// ESTRATÉGIA: um MonoBehaviour na cena Invasion. Cria a carteira de Plasma e os Fleet Upgrades no Awake,
// escuta o PlanetHealth (acerto = Plasma; morte = próximo planeta) e manda o PlanetAI montar cada planeta.
// Cumpre IRunFlow para a PauseView da Fase 8 funcionar aqui também.
public sealed class SiegeController : MonoBehaviour, IRunFlow
{
[SerializeField] private InvasionBalance _balance;
[SerializeField] private PlanetAI _ai;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private FleetLauncher _launcher;
[SerializeField] private ExplosionPool _explosions;

private bool _running;

public ShardWallet Plasma { get; private set; }
public FleetUpgrades Fleet { get; private set; }
public int PlanetNumber => _ai.PlanetNumber;
public bool IsFortress => _balance.IsFortress(PlanetNumber);
public int PlanetsDestroyed { get; private set; }
public float TimeRemaining { get; private set; }
public float ElapsedSeconds { get; private set; }
public bool IsRunning => _running;

public event Action<int> PlanetDestroyed; // o número do planeta que caiu
public event Action<InvasionSummary> RunEnded;

// Cria a economia do jogador no Awake: a gaveta lê Plasma e Fleet no Start dela.
private void Awake()
{
Plasma = new ShardWallet();
Fleet = new FleetUpgrades(_balance.FleetUpgrades);
}

private void OnEnable()
{
_planet.Damaged += HandlePlanetHit;
_planet.Died += HandlePlanetDied;
}

private void OnDisable()
{
_planet.Damaged -= HandlePlanetHit;
_planet.Died -= HandlePlanetDied;
}

// Começa a run: Plasma inicial, o planeta 1 e o Siege Timer.
private void Start()
{
Plasma.Add(_balance.StartingPlasma);
_ai.SetupPlanet(1);
TimeRemaining = _balance.SiegeSeconds;
_running = true;
}

// Renda de Plasma e Siege Timer. Pausado, deltaTime é 0: nada anda.
private void Update()
{
if (!_running) return;
float deltaTime = Time.deltaTime;
ElapsedSeconds += deltaTime;
Plasma.Add((_balance.PlasmaPerSecond + Fleet.Value(FleetStat.PlasmaFlow)) * deltaTime);

TimeRemaining -= deltaTime;
if (TimeRemaining <= 0f)
{
TimeRemaining = 0f;
EndRun(gaveUp: false); // o planeta resistiu: a run acaba (Seção 4.7)
}
}

// Cada golpe que passa pelo Energy Shield e acerta o planeta devolve Plasma (Seção 4.7).
private void HandlePlanetHit(float damage)
{
if (_running) Plasma.Add(_balance.PlasmaPerHit);
}

// O planeta caiu: prêmio, explosão grande e o próximo Target Planet com o timer cheio.
private void HandlePlanetDied()
{
if (!_running) return;
int number = PlanetNumber;
PlanetsDestroyed++;
Plasma.Add(_balance.PlanetReward(number));
_explosions.Play(ExplosionKind.Mothership, WorldLayout.PlanetCenter);
PlanetDestroyed?.Invoke(number);

_ai.SetupPlanet(number + 1);
TimeRemaining = _balance.SiegeSeconds;
}

// Botão "Give up" da pausa (IRunFlow). Mesmo ritual do RunController.GiveUp.
public void GiveUp()
{
if (!_running) return;
Services.Clock.Resume(PauseReason.Manual);
EndRun(gaveUp: true);
}

public void PlayAgain() => Services.Scenes.Load(GameScene.Invasion);
public void GoToMenu() => Services.Scenes.Load(GameScene.MainMenu);

// Congela o mundo atrás do Results e avisa as telas.
private void EndRun(bool gaveUp)
{
_running = false;
Services.Clock.Pause(PauseReason.Modal);
RunEnded?.Invoke(new InvasionSummary
{
planetsDestroyed = PlanetsDestroyed,
shipsLaunched = _launcher.ShipsLaunched,
seconds = ElapsedSeconds,
gaveUp = gaveUp,
});
}

#if UNITY_EDITOR || DEVELOPMENT_BUILD
// Atalho de teste (InvasionDevTools): o Siege Timer vai para 1 s.
public void DevSkipTimer() => TimeRemaining = Mathf.Min(TimeRemaining, 1f);
#endif
}
}

Passo 6 — Lançar naves: FleetLauncher e SectorPicker​

// Caminho: Assets/_Project/Scripts/Invasion/FleetLauncher.cs
using System;
using System.Collections.Generic;
using Armageddon.Enemies;
using Armageddon.Planets;
using Armageddon.Waves;
using Armageddon.World;
using UnityEngine;

namespace Armageddon.Invasion
{
// POR QUE: no Invasion, quem cria os inimigos é o jogador (Seção 4.7). Lançar uma nave é cobrar o Plasma,
// escolher o ponto no setor selecionado e criar a nave com os Fleet Upgrades.
// ESTRATÉGIA: o "WaveDirector do jogador". Guarda o setor escolhido e usa o mesmo EnemyPool das Waves
// (com os multiplicadores dos Fleet Upgrades no lugar da escalada por Wave). A Mothership é criada como
// no MothershipSpawner (Fase 7), mas a 9 u e com os valores da Wave 1.
public sealed class FleetLauncher : MonoBehaviour
{
[SerializeField] private InvasionBalance _balance;
[SerializeField] private SiegeController _siege;
[SerializeField] private EnemyPool _enemies;
[SerializeField] private Mothership _mothershipPrefab;
[SerializeField] private PlanetHealth _planet;
[SerializeField] private ExplosionPool _explosions;

private readonly List<Mothership> _motherships = new List<Mothership>();

public int SelectedSector { get; private set; }
public int ShipsLaunched { get; private set; }

public event Action<int> SectorChanged;
public event Action Launched; // os cards reavaliam se podem lançar
public event Action<Mothership> MothershipDestroyed; // Shards da IA (PlanetAI)

// Escolhe o Spawn Sector (0 a 5). Repeat aceita qualquer número e traz para 0–5.
public void SelectSector(int sector)
{
SelectedSector = (int)Mathf.Repeat(sector, WaveDirector.SectorCount);
SectorChanged?.Invoke(SelectedSector);
}

public bool IsUnlocked(ShipOffer offer) => offer.unlockPlanet <= _siege.PlanetNumber;

// Pode lançar: run rodando, nave liberada, Plasma suficiente e, na Mothership, nenhuma em campo.
public bool CanLaunch(ShipOffer offer)
{
return _siege.IsRunning
&& IsUnlocked(offer)
&& _siege.Plasma.Balance >= offer.cost
&& !(offer.IsMothership && _motherships.Count > 0);
}

// Lança a nave (ou o cacho, no Swarmer) no setor escolhido. Devolve se deu certo.
public bool TryLaunch(ShipOffer offer)
{
if (!CanLaunch(offer)) return false;
_siege.Plasma.TrySpend(offer.cost);
ShipsLaunched++;

var fleet = _siege.Fleet;
float hull = fleet.Multiplier(FleetStat.Hull);
float warheads = fleet.Multiplier(FleetStat.Warheads);
float sectorStart = SelectedSector * WaveDirector.SectorArc;

if (offer.IsMothership)
{
SpawnMothership(sectorStart + WaveDirector.SectorArc * 0.5f, hull, warheads);
}
else
{
// Um ponto aleatório do setor, longe das bordas dele, a 9 u do centro (dentro da tela).
float margin = _balance.SectorMargin;
float radians = (sectorStart + UnityEngine.Random.Range(margin, WaveDirector.SectorArc - margin)) * Mathf.Deg2Rad;
Vector2 position = WorldLayout.PlanetCenter + new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)) * _balance.DeployRadius;
_enemies.Spawn(offer.enemy, position, hull, warheads, fleet.Multiplier(FleetStat.Thrusters));
}

Launched?.Invoke();
return true;
}

// A Mothership do jogador: valores da Wave 1 (Seção 4.7), com Hull e Warheads. Os Thrusters não mudam
// a velocidade dela (a espiral é fixa, Seção 4.5).
private void SpawnMothership(float angleDegrees, float hull, float warheads)
{
var mothership = Instantiate(_mothershipPrefab, transform);
mothership.Spawn(1, angleDegrees, hull, warheads, _planet, _enemies, _balance.DeployRadius);
mothership.Died += HandleMothershipDied;
_motherships.Add(mothership);
}

// Mesma limpeza do MothershipSpawner.HandleDied (Fase 7).
private void HandleMothershipDied(Mothership mothership)
{
mothership.Died -= HandleMothershipDied;
_motherships.Remove(mothership);
_explosions.Play(ExplosionKind.Mothership, mothership.Position);
MothershipDestroyed?.Invoke(mothership);
Destroy(mothership.gameObject);
Launched?.Invoke(); // a Mothership pode ser lançada de novo: os cards se atualizam
}
}
}
// Caminho: Assets/_Project/Scripts/Invasion/SectorPicker.cs
using Armageddon.Combat;
using Armageddon.Waves;
using Armageddon.World;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;

namespace Armageddon.Invasion
{
// POR QUE: no Invasion, o jogador escolhe o Spawn Sector tocando no espaço em volta do planeta, ou com
// as teclas 1 a 6 (Seção 4.7). Toques na gaveta e nos botões não podem mudar o setor.
// ESTRATÉGIA: lê o Input System direto (Pointer cobre mouse E toque), descarta o toque que caiu em cima
// da UI (EventSystem), converte a tela para a vista e a vista para o CHÃO (IsoProjection.ViewToGround,
// Fase 2), porque os setores são arcos do chão. Também mantém as setas do SpawnSectorIndicator
// mostrando o setor escolhido.
public sealed class SectorPicker : MonoBehaviour
{
[SerializeField] private FleetLauncher _launcher;
[SerializeField] private SpawnSectorIndicator _arrows;

private void OnEnable() => _launcher.SectorChanged += _arrows.ShowSelection;
private void OnDisable() => _launcher.SectorChanged -= _arrows.ShowSelection;

// As setas aparecem já no primeiro frame, com o setor 0 escolhido.
private void Start()
{
_launcher.SelectSector(_launcher.SelectedSector);
}

private void Update()
{
if (Time.deltaTime <= 0f) return; // pausado: a pausa não muda o setor

// Teclas 1 a 6: Key.Digit1 até Key.Digit6 são seguidas no enum, então Digit1 + i funciona.
var keyboard = Keyboard.current;
if (keyboard != null)
{
for (int i = 0; i < WaveDirector.SectorCount; i++)
{
if (keyboard[Key.Digit1 + i].wasPressedThisFrame) _launcher.SelectSector(i);
}
}

// Pointer.current = o último ponteiro usado (mouse ou dedo). "press" é o clique ou o toque.
var pointer = Pointer.current;
if (pointer == null || !pointer.press.wasPressedThisFrame) return;
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) return; // tocou na UI

var view = Camera.main;
if (view == null) return;
Vector2 world = view.ScreenToWorldPoint(pointer.position.ReadValue());
Vector2 ground = IsoProjection.ViewToGround(world); // o ponto do plano de voo, de volta no chão
float degrees = Mathf.Repeat(Reflector.AngleOf(ground), 360f); // 0 = eixo x do chão, anti-horário
_launcher.SelectSector(Mathf.FloorToInt(degrees / WaveDirector.SectorArc));
}
}
}

Por que Reflector.AngleOf? É a mesma conta de "ângulo de um ponto em volta do planeta" que o Reflector já precisava (Fase 8A). Reaproveitar evita uma segunda versão da mesma fórmula.

Passo 7 — As telas do Invasion: ShipCard, FleetCard, InvasionDrawer, InvasionHudView, IntelPanel, InvasionResultsView​

// Caminho: Assets/_Project/Scripts/UI/ShipCard.cs
using Armageddon.Invasion;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: cada nave da aba Deploy precisa de um card que mostra custo (ou o planeta que a libera) e
// lança a nave com um toque, sem pausar (Pilar 1).
// ESTRATÉGIA: o mesmo molde do StatCard (Fase 6): Bind recebe os dados, o card escuta os eventos que
// podem mudá-lo (saldo, planeta novo, lançamentos) e se redesenha só nessas horas.
public sealed class ShipCard : MonoBehaviour
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _name;
[SerializeField] private TMP_Text _cost;
[SerializeField] private Button _button;

private ShipOffer _offer;
private SiegeController _siege;
private FleetLauncher _launcher;

private void Awake()
{
_button.onClick.AddListener(() => _launcher.TryLaunch(_offer));
}

private void OnDestroy()
{
Unbind();
}

// Liga o card a uma nave. Chamado uma vez pela InvasionDrawer.
public void Bind(ShipOffer offer, SiegeController siege, FleetLauncher launcher)
{
Unbind();
_offer = offer;
_siege = siege;
_launcher = launcher;
_icon.sprite = offer.icon;
_name.text = offer.displayName;
_siege.Plasma.BalanceChanged += Refresh;
_siege.PlanetDestroyed += HandlePlanetDestroyed;
_launcher.Launched += Refresh;
Refresh();
}

private void Unbind()
{
if (_siege == null) return;
_siege.Plasma.BalanceChanged -= Refresh;
_siege.PlanetDestroyed -= HandlePlanetDestroyed;
_launcher.Launched -= Refresh;
_siege = null;
}

// Um planeta caiu: o próximo pode liberar esta nave.
private void HandlePlanetDestroyed(int planet) => Refresh();

// Bloqueada: mostra o planeta que libera. Liberada: o custo, e o botão aceso se der para lançar agora.
private void Refresh()
{
bool unlocked = _launcher.IsUnlocked(_offer);
_cost.text = unlocked ? _offer.cost.ToString() : $"Planet {_offer.unlockPlanet}";
_button.interactable = _launcher.CanLaunch(_offer);
}
}
}
// Caminho: Assets/_Project/Scripts/UI/FleetCard.cs
using Armageddon.Invasion;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: os Fleet Upgrades aparecem como cards, iguais aos de Stat (nível, valor atual > próximo, custo).
// ESTRATÉGIA: cópia do StatCard (Fase 6) apontando para os FleetUpgrades e para a carteira de Plasma.
public sealed class FleetCard : MonoBehaviour
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _name;
[SerializeField] private TMP_Text _level;
[SerializeField] private TMP_Text _value;
[SerializeField] private TMP_Text _cost;
[SerializeField] private Button _button;

private FleetUpgradeDefinition _definition;
private SiegeController _siege;

private void Awake()
{
_button.onClick.AddListener(() => _siege.Fleet.TryPurchase(_definition.stat, _siege.Plasma));
}

private void OnDestroy()
{
Unbind();
}

public void Bind(FleetUpgradeDefinition definition, SiegeController siege)
{
Unbind();
_definition = definition;
_siege = siege;
_icon.sprite = definition.icon;
_name.text = definition.displayName;
_siege.Fleet.Changed += HandleChanged;
_siege.Plasma.BalanceChanged += Refresh;
Refresh();
}

private void Unbind()
{
if (_siege == null) return;
_siege.Fleet.Changed -= HandleChanged;
_siege.Plasma.BalanceChanged -= Refresh;
_siege = null;
}

private void HandleChanged(FleetStat stat)
{
if (stat == _definition.stat) Refresh();
}

// Mesmo formato do StatCard: "Lv 2", "+20% > +30%", custo; no teto, "MAX".
private void Refresh()
{
var fleet = _siege.Fleet;
var stat = _definition.stat;
int level = fleet.Level(stat);
_level.text = $"Lv {level}";
if (fleet.IsMaxed(stat))
{
_value.text = _definition.Format(fleet.Value(stat));
_cost.text = "MAX";
_button.interactable = false;
return;
}
_value.text = $"{_definition.Format(fleet.Value(stat))} > {_definition.Format(fleet.ValueAtLevel(stat, level + 1))}";
_cost.text = fleet.NextCost(stat).ToString();
_button.interactable = fleet.CanPurchase(stat, _siege.Plasma);
}
}
}
// Caminho: Assets/_Project/Scripts/UI/InvasionDrawer.cs
using System.Collections.Generic;
using Armageddon.Invasion;
using Armageddon.World;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: a gaveta do Invasion tem duas abas, Deploy (naves) e Fleet (Fleet Upgrades), e o saldo é de
// Plasma (Seção 6.3). O comportamento é o da gaveta da Fase 6: abrir, fechar, deslizar a câmera.
// ESTRATÉGIA: os cards das duas abas são criados UMA vez, no Start, no mesmo container; trocar de aba
// só liga uns e desliga outros. São poucos (6 naves + 4 upgrades), então não vale reaproveitar como na Fase 6.
public sealed class InvasionDrawer : MonoBehaviour
{
public enum Tab { None = -1, Deploy = 0, Fleet = 1 }

[SerializeField] private SiegeController _siege;
[SerializeField] private FleetLauncher _launcher;
[SerializeField] private InvasionBalance _balance;
[SerializeField] private CameraRig _camera;
[SerializeField] private Button[] _tabButtons = new Button[2]; // na ordem Deploy, Fleet
[SerializeField] private TMP_Text _plasmaText;
[SerializeField] private GameObject _panel;
[SerializeField] private Transform _cardContainer;
[SerializeField] private ShipCard _shipCardPrefab;
[SerializeField] private FleetCard _fleetCardPrefab;

private readonly List<GameObject> _shipCards = new List<GameObject>();
private readonly List<GameObject> _fleetCards = new List<GameObject>();

public Tab OpenTab { get; private set; } = Tab.None;

private void Awake()
{
for (int i = 0; i < _tabButtons.Length; i++)
{
var tab = (Tab)i;
_tabButtons[i].onClick.AddListener(() => SetOpen(OpenTab == tab ? Tab.None : tab));
}
}

// Start: o SiegeController já criou Plasma e Fleet no Awake dele.
private void Start()
{
foreach (var offer in _balance.Ships)
{
var card = Instantiate(_shipCardPrefab, _cardContainer);
card.Bind(offer, _siege, _launcher);
_shipCards.Add(card.gameObject);
}
foreach (var upgrade in _balance.FleetUpgrades)
{
var card = Instantiate(_fleetCardPrefab, _cardContainer);
card.Bind(upgrade, _siege);
_fleetCards.Add(card.gameObject);
}

_siege.Plasma.BalanceChanged += RefreshPlasma;
RefreshPlasma();
SetOpen(Tab.None);
}

private void OnDestroy()
{
if (_siege != null && _siege.Plasma != null) _siege.Plasma.BalanceChanged -= RefreshPlasma;
}

// Abre uma aba (ou fecha, com Tab.None) e avisa a câmera, como a UpgradeDrawer.
public void SetOpen(Tab tab)
{
OpenTab = tab;
bool open = tab != Tab.None;
_panel.SetActive(open);
_camera.SetDrawerOpen(open);
foreach (var card in _shipCards) card.SetActive(tab == Tab.Deploy);
foreach (var card in _fleetCards) card.SetActive(tab == Tab.Fleet);
}

private void RefreshPlasma()
{
_plasmaText.text = _siege.Plasma.Balance.ToString();
}
}
}
// Caminho: Assets/_Project/Scripts/UI/InvasionHudView.cs
using Armageddon.Core;
using Armageddon.Invasion;
using Armageddon.Planets;
using Armageddon.World;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: no Invasion, a barra de HP é a do planeta INIMIGO, e em vez de "Wave 7 · 18s" o topo mostra
// "Planet 3 · 87s" (Seção 4.7).
// ESTRATÉGIA: a mesma HudView da Fase 8, lendo o SiegeController no lugar do WaveDirector. Só reescreve
// os textos quando o número mostrado muda.
public sealed class InvasionHudView : MonoBehaviour
{
[SerializeField] private PlanetHealth _planet;
[SerializeField] private SiegeController _siege;
[SerializeField] private Image _healthFill;
[SerializeField] private TMP_Text _healthText;
[SerializeField] private TMP_Text _planetText;
[SerializeField] private TMP_Text _timerText;
[SerializeField] private Button _pauseButton;

private int _shownHealth = -1;
private int _shownMaxHealth = -1;
private int _shownPlanet = -1;
private int _shownSeconds = -1;

private void Awake()
{
_healthFill.sprite = PixelSprite.White;
_healthFill.type = Image.Type.Filled;
_healthFill.fillMethod = Image.FillMethod.Horizontal;
_pauseButton.onClick.AddListener(() => Services.Clock.Pause(PauseReason.Manual));
}

private void LateUpdate()
{
_healthFill.fillAmount = _planet.Max > 0f ? _planet.Current / _planet.Max : 0f;
int health = Mathf.CeilToInt(_planet.Current);
int maxHealth = Mathf.CeilToInt(_planet.Max);
if (health != _shownHealth || maxHealth != _shownMaxHealth)
{
_shownHealth = health;
_shownMaxHealth = maxHealth;
_healthText.text = $"{health}/{maxHealth}";
}

if (_siege.PlanetNumber != _shownPlanet)
{
_shownPlanet = _siege.PlanetNumber;
_planetText.text = _siege.IsFortress ? $"Planet {_shownPlanet} · Fortress" : $"Planet {_shownPlanet}";
}

int seconds = Mathf.CeilToInt(_siege.TimeRemaining);
if (seconds != _shownSeconds)
{
_shownSeconds = seconds;
_timerText.text = $"{seconds}s";
}
}
}
}
// Caminho: Assets/_Project/Scripts/UI/IntelPanel.cs
using System.Text;
using Armageddon.Combat;
using Armageddon.Economy;
using Armageddon.Invasion;
using Armageddon.Planets;
using TMPro;
using UnityEngine;

namespace Armageddon.UI
{
// POR QUE: o desafio do Invasion é LER a defesa (Seção 4.7): quantos Satellites, se tem Multishot,
// Energy Shield, Reflector. Sem esse painel, a escolha de nave seria um chute.
// ESTRATÉGIA: um texto só, no canto de cima à direita (onde o Last Stand mostra os inimigos da Wave),
// remontado 4 vezes por segundo. StringBuilder monta o texto sem criar várias strings soltas.
public sealed class IntelPanel : MonoBehaviour
{
[SerializeField] private PlanetAI _ai;
[SerializeField] private SatelliteOrbit _orbit;
[SerializeField] private EnergyShield _shield;
[SerializeField] private TMP_Text _text;
[SerializeField] private float _interval = 0.25f;

private readonly StringBuilder _builder = new StringBuilder();
private float _timer;

// unscaledDeltaTime: o painel continua certo mesmo com o jogo pausado (ver ReviveOfferView).
private void Update()
{
_timer -= Time.unscaledDeltaTime;
if (_timer > 0f || _ai.Stats == null) return;
_timer = _interval;

var stats = _ai.Stats;
_builder.Clear();
_builder.AppendLine(_ai.Profile.DisplayName);
_builder.AppendLine($"Satellites {_orbit.Satellites.Count}");
_builder.AppendLine($"Damage {stats.Value(StatId.Damage):0} · {stats.Value(StatId.AttackSpeed):0.0}/s");
if (stats.Value(StatId.MultishotChance) > 0f) _builder.AppendLine($"Multishot {stats.Value(StatId.MultishotChance) * 100f:0}%");
if (_shield.MaxCharges > 0) _builder.AppendLine($"Shield {_shield.Charges}/{_shield.MaxCharges}");
if (stats.Value(StatId.ReflectShield) > 0f) _builder.AppendLine($"Reflect {stats.Value(StatId.ReflectShield):0}");
_builder.Append($"Shards {_ai.Wallet.Balance}");
_text.text = _builder.ToString();
}
}
}
// Caminho: Assets/_Project/Scripts/UI/InvasionResultsView.cs
using Armageddon.Invasion;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace Armageddon.UI
{
// POR QUE: o Results do Invasion mostra planetas destruídos, naves lançadas e o tempo (Seção 4.7).
// ESTRATÉGIA: a ResultsView da Fase 8, ouvindo o SiegeController.
public sealed class InvasionResultsView : MonoBehaviour
{
[SerializeField] private SiegeController _siege;
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text _planetsText;
[SerializeField] private TMP_Text _shipsText;
[SerializeField] private TMP_Text _timeText;
[SerializeField] private Button _playAgainButton;
[SerializeField] private Button _menuButton;

private void Awake()
{
_playAgainButton.onClick.AddListener(() => _siege.PlayAgain());
_menuButton.onClick.AddListener(() => _siege.GoToMenu());
_root.SetActive(false);
}

private void OnEnable() => _siege.RunEnded += Show;
private void OnDisable() => _siege.RunEnded -= Show;

private void Show(InvasionSummary summary)
{
int seconds = Mathf.FloorToInt(summary.seconds);
_planetsText.text = summary.planetsDestroyed.ToString();
_shipsText.text = summary.shipsLaunched.ToString();
_timeText.text = $"{seconds / 60}:{seconds % 60:00}";
_root.SetActive(true);
}
}
}

Passo 8 — Teclas de teste: InvasionDevTools​

// Caminho: Assets/_Project/Scripts/Invasion/InvasionDevTools.cs
using UnityEngine;
#if UNITY_EDITOR || DEVELOPMENT_BUILD
using Armageddon.Planets;
using UnityEngine.InputSystem;
#endif

namespace Armageddon.Invasion
{
// POR QUE: testar a Fortress ou o fim do timer jogando de verdade leva minutos.
// ESTRATÉGIA: como as outras DevTools, só existe no Editor e em builds de desenvolvimento.
// P = +500 Plasma; N = derruba o planeta atual; T = Siege Timer em 1 s.
public sealed class InvasionDevTools : MonoBehaviour
{
#if UNITY_EDITOR || DEVELOPMENT_BUILD
[SerializeField] private SiegeController _siege;
[SerializeField] private PlanetHealth _planet;

private void Update()
{
var keyboard = Keyboard.current;
if (keyboard == null) return;

if (keyboard.pKey.wasPressedThisFrame) _siege.Plasma.Add(500f);
if (keyboard.tKey.wasPressedThisFrame) _siege.DevSkipTimer();
if (keyboard.nKey.wasPressedThisFrame)
{
// Golpes enormes até o planeta cair. Até 4, porque as 3 primeiras podem ser gastas pelo Energy Shield.
int before = _siege.PlanetsDestroyed;
for (int i = 0; i < 4 && _siege.PlanetsDestroyed == before; i++) _planet.TakeDamage(1000000000f);
}
}
#endif
}
}

Passo 9 — Os assets do Invasion​

  1. Perfis da IA, em Assets/_Project/Data/Invasion/, Create → Armageddon → AI Profile, três vezes. Planet Frames = as 32 células de SPR_Planet_<Core>_Classic_Rotate. Em Modifiers, cada item tem Stat, Add e Multiply: um modificador que só multiplica fica com Add 0; um que só soma fica com Multiply 1 (Multiply 0 zera o Stat).
AssetDisplay NameModifiers (Seção 5.2)Purchase Order
AiProfile_TerraTerra CorenenhumDamage, Hitpoints, AttackSpeed, OrbitSpeed, Regeneration, AttackRange, EnergyShield, CriticalChance, DefenseAbsolute, MultishotChance, CriticalFactor, ReflectShield, DefenseRelative, MultishotTargets
AiProfile_IceIce Core× 1.3 em Hitpoints, Regeneration, DefenseAbsolute, DefenseRelative, EnergyShield e ReflectShield; × 0.8 em AttackSpeed; + 0.5 em AttackRangeHitpoints, DefenseAbsolute, EnergyShield, Damage, ReflectShield, Regeneration, DefenseRelative, AttackSpeed, OrbitSpeed, Hitpoints, ReflectShield
AiProfile_MagmaMagma Core× 1.3 em Damage; × 0.75 em Hitpoints; − 0.5 em AttackRange; + 15 em OrbitSpeedDamage, AttackSpeed, CriticalChance, MultishotChance, CriticalFactor, OrbitSpeed, MultishotTargets, Damage, Hitpoints, AttackRange

As ordens de compra são as do protótipo web. A Seção 4.7 as deixa Em aberto: ajuste jogando.

  1. Balanceamento, em Data/Invasion/, Create → Armageddon → Invasion Balance com o nome InvasionBalance. Os números do topo já vêm com os valores da Seção 4.7. Preencha as listas:
    • Ships (na ordem dos cards):
Display NameIconEnemyCostUnlock Planet
Grunt1ª célula de SPR_Enemy_GruntEnemy_Grunt41
Scout1ª célula de SPR_Enemy_ScoutEnemy_Scout51
Swarmer x81ª célula de SPR_Enemy_SwarmerEnemy_Swarmer82
Gunner1ª célula de SPR_Enemy_GunnerEnemy_Gunner103
Brute1ª célula de SPR_Enemy_BruteEnemy_Brute184
Mothership1ª célula de SPR_Enemy_Mothership(vazio)1206
  • Fleet Upgrades:
StatDisplay NameIcon (célula de SPR_UI_Icons)Per LevelCapBase CostIs Percent
HullHull290.1015marcado
WarheadsWarheads300.1015marcado
ThrustersThrusters310.050.520marcado
PlasmaFlowPlasma Flow320.5020desmarcado
  • Profiles: os 3 AiProfile.

O Swarmer do Invasion sempre vem em cacho de 8, porque o Enemy_Swarmer tem Cluster Size 8 (Fase 4). O cacho de 4 das primeiras Waves é um ajuste só do protótipo web.

Passo 10 — A cena Invasion​

  1. Duplicar a Gameplay: salve a cena Gameplay. Na janela Project, selecione Scenes/Gameplay e aperte Ctrl+D; renomeie a cópia para Invasion (o nome precisa ser igual ao do enum GameScene). Em File → Build Profiles (ou Build Settings), arraste Invasion para a lista de cenas, depois da Gameplay.
  2. Abra a Invasion e apague o que é do Last Stand: em [Systems], WaveDirector, WaveDevTools, MothershipSpawner, MothershipDevTools, RunEconomy, EconomyDevTools, RunController e RunDevTools; no HUDCanvas, HUD, BossHealthBar, UpgradeDrawer e ResultsView. Fica todo o resto: o planeta (com o Energy Shield), o SatelliteOrbit, os pools, o Reflector, o fundo, a câmera e a PauseView.
  3. Setas: no SpawnSectorIndicator, deixe o campo Waves vazio.
  4. Sistemas: em [Systems], crie:
    • PlanetAI (componente PlanetAI): Balance = InvasionBalance; Catalog = StatCatalog; Orbit, Planet (o PlanetHealth), Shield (o EnergyShield), Reflector, Enemies (o EnemyPool) = os objetos da cena; Planet Animator = o SpriteAnimator do filho View do Planet; Launcher = o FleetLauncher abaixo.
    • SiegeController (componente SiegeController): Balance, Ai = PlanetAI, Planet = o PlanetHealth, Launcher = FleetLauncher, Explosions = o ExplosionPool.
    • FleetLauncher (componente FleetLauncher): Balance, Siege = SiegeController, Enemies, Mothership Prefab = o prefab Mothership da Fase 7, Planet, Explosions.
    • SectorPicker (componente SectorPicker): Launcher = FleetLauncher; Arrows = o SpawnSectorIndicator.
    • InvasionDevTools: Siege e Planet.
  5. Pausa: na PauseView, Run = o SiegeController.
  6. HUD: um objeto InvasionHUD no HUDCanvas com o mesmo layout do HUD da Fase 8 (barra de HP em cima à esquerda, textos e botão de pausa em cima à direita) e o componente InvasionHudView, com Planet Text no lugar do texto da Wave. Abaixo do botão de pausa, um IntelPanel: uma Image SPR_UI_Panel (Sliced) de 96 × 56 com um TMP (m5x7, 16, alinhado em cima à esquerda) dentro, e o componente IntelPanel (Ai, Orbit, Shield e Text).
  7. Gaveta: um objeto InvasionDrawer com o mesmo layout do UpgradeDrawer da Fase 6, mas com 2 abas (TabDeploy e TabFleet, ícones nas células 26 e 27 de SPR_UI_Icons) e o ícone de Plasma (célula 28) ao lado do PlasmaText. O painel tem só a Scroll View (sem o SatellitePanel). Componente InvasionDrawer: Siege, Launcher, Balance, Camera = o CameraRig, Tab Buttons (Deploy, Fleet), Plasma Text, Panel, Card Container = o Content da Scroll View, e os dois prefabs abaixo.
  8. Prefabs dos cards: duplique o prefab StatCard duas vezes na janela Project. Em ShipCard, remova o componente Stat Card, apague o texto Level e adicione Ship Card (ligue Icon, Name, Cost e Button). Em FleetCard, remova o Stat Card e adicione Fleet Card (todos os campos). Nos dois, troque o ícone de Shards ao lado do custo pelo de Plasma (célula 28).
  9. Results: um objeto InvasionResultsView com o layout da ResultsView da Fase 8: título Invasion over, as linhas Planets, Ships e Time, e os botões Play again e Menu. Componente InvasionResultsView com os campos ligados.
  10. Ordem do Canvas (de cima para baixo): InvasionHUD, IntelPanel, InvasionDrawer, PauseView, InvasionResultsView.

Passo 11 — Commit​

git add . e git commit -m "Phase 8B: invasion mode (planet AI, siege, fleet launcher)".

✅ Checkpoint (abra a cena Invasion e aperte Play):

  • O planeta aparece com o sprite de um Core sorteado, 1 Satellite e HP cheio. O topo mostra Planet 1 · 120s, contando para baixo. O painel de leitura mostra o Core, Satellites 1, o dano e os Shards da IA, que caem logo no começo (ela gastou nas compras).
  • As 6 setas aparecem na borda, uma forte e 5 apagadas. Clicar no espaço perto de outra seta (ou apertar 1 a 6) troca a seta forte. Clicar na gaveta não troca.
  • O saldo de Plasma começa em 20 e sobe 3 por segundo. Na aba Deploy, Grunt e Scout estão liberados; Swarmer mostra Planet 2. Tocar no Grunt lança uma nave no setor escolhido, a 9 u do centro, e desconta 4.
  • Uma rajada de naves no Quadrant que o Satellite acabou de deixar acerta o planeta: o HP desce e o Plasma sobe 1 por acerto. Naves destruídas aumentam os Shards da IA no painel, e de tempos em tempos ela compra algo (o dano ou os Satellites mudam no painel).
  • N derruba o planeta: explosão grande, Plasma +10, Planet 2 com outro Core e o timer de volta a 120. No planeta 5, o topo mostra · Fortress, há 4 Satellites e o Reflector gira em volta.
  • Na aba Fleet, comprar Hull deixa as naves novas mais resistentes; Plasma Flow acelera a renda.
  • T (timer em 1 s) termina a run: o jogo congela e o Results mostra os planetas destruídos, as naves lançadas e o tempo. Play again recarrega o Invasion; Menu vai para o Main Menu.
  • A cena Gameplay continua funcionando como antes, inclusive a pausa (agora pela IRunFlow).

Problemas comuns:

  • NullReferenceException no PlanetAI.SetupPlanet: a lista Profiles do InvasionBalance está vazia, ou um perfil está sem Planet Frames.
  • Todo planeta nasce com HP 0 ou dano 0: algum modificador de perfil ficou com Multiply 0. Um modificador que só soma precisa de Multiply 1.
  • Clicar nos cards também troca o setor: falta o EventSystem na cena (ele veio da Gameplay; confira se não foi apagado) ou o card não tem Raycast Target na Image.
  • No celular, tocar num card troca o setor: em algumas versões do Input System, IsPointerOverGameObject responde pelo toque anterior. Se acontecer, guarde o toque no Update e confira a UI no frame seguinte, ou troque a checagem por um EventSystem.RaycastAll na posição do toque.
  • A pausa da Gameplay parou de funcionar: o campo Run da PauseView ficou vazio depois da mudança de tipo. Arraste o RunController de novo.
  • A Mothership do jogador nasce na borda e demora: o Spawn da Mothership não recebeu o parâmetro startDistance (Passo 1).

Próxima fase: Fase 9 — Progressão entre runs. Ela também salva o Best Planet junto do Best Wave e põe a escolha entre Last Stand e Invasion na Core Select (liberando o Invasion com a conquista Holding the Line).