Capibaribe Vivo — Guia Unity
Parte 2 — Guia de Implementação Unity
Guia fase a fase para implementar o MVP do Capibaribe Vivo em Unity 6, com build Web (o jogo roda no navegador da escola, Parte 1 §1). Os números de proposta da Parte 1 ficam num asset (
GameConfig) e nos...Def, para balancear sem mexer no código.Cada fase termina com algo que dá para jogar no Play Mode. Ao fim da Fase 5, um trecho inteiro funciona; as Fases 6 e 7 transformam esse trecho em jogo (mapa, vários trechos, personagem, narração).
Convenções
- Nomes no projeto em inglês, comentários em português. Cada classe começa com
// POR QUE:e// ESTRATÉGIA:, e cada método tem um comentário curto acima dele. Glossário: trecho éStretch, Rio Vivo éRiverHealth, porão éCargo, puçá éNet, Ecoponto éEcopoint, lixeira éRecycleBin, fonte de poluição éPollutionSource, Álbum éAlbum. - Namespaces:
CapibaribeVivo.Core: pausa, save, narração e interfaces comuns.CapibaribeVivo.Data: ScriptableObjects (configuração, itens de lixo, fauna, trechos, personagens, cartões do álbum).CapibaribeVivo.River: o rio, a correnteza, a cor da água e o Rio Vivo.CapibaribeVivo.Boat: o barco, o controle, o puçá e o porão.CapibaribeVivo.Trash: o lixo boiando e o espalhamento inicial.CapibaribeVivo.Sorting: o Ecoponto e a tela de separação.CapibaribeVivo.Fauna: os bichos.CapibaribeVivo.Sources: as fontes de poluição.CapibaribeVivo.Progress: trechos, mapa, álbum e personagens.CapibaribeVivo.UI: HUD, cartões, diálogos e avisos.
- Campos do Inspector:
[SerializeField] privatecom_camelCase. Dados de ScriptableObject e de save: campos públicoscamelCase. - Textos na tela sem emoji, porque a fonte padrão do TextMeshPro não tem emojis.
Arquitetura em uma página
Menu (personagem) ──▶ Map (trechos + Álbum) ──▶ Stretch_XX (um trecho jogável) ──postal──▶ Map
Dentro de um trecho:
Clique/toque ──▶ ClickToMoveInput ──alvo──▶ BoatController (Rigidbody2D) ── margens (colliders) seguram
│ clicou em algo usável │
▼ ▼ filho
IClickable: Ecopoint / PollutionSource / BoatNet ──recolhe──▶ BoatCargo ──cheio──▶ CargoFullHint
FaunaAnimal │
▼ TakeAll
TrashScatter (lixo inicial) ─┐ Ecopoint ──▶ SortingPanel (arrastar ▶ RecycleBin)
PollutionSource (aberta) ────┴──▶ FloatingTrash (deriva com RiverFlow) │ pontos
▼
PollutionSource.Close ──pontos e teto──▶ RiverHealth (0..1, com teto) ──Changed──▶ WaterTint, RiverHealthBar,
│ FaunaSpawner (bichos + Álbum)
└──Completed──▶ StretchManager ──▶ postal, save, volta ao mapa
Sempre vivos: SaveSystem (PlayerPrefs, JSON), AlbumService, Narrator (voz), GamePause (Time.timeScale)
- Uma cena por trecho. Cada trecho tem um rio desenhado à mão (margens, marcos, Ecoponto e fontes no lugar certo), então é uma cena própria (
Stretch_01_Varzea, ...), montada a partir de um modelo (Fase 6). - Física 2D só para o barco, o lixo e as margens. O barco é um
Rigidbody2Dempurrado para o alvo; as margens são colliders estáticos; o lixo boia comRigidbody2De bate nas margens. Todo o resto (bichos, fontes, Ecoponto) usa colliders só como área de clique ou de chegada. - Pausa por
Time.timeScale. Toda tela que cobre o jogo (separação, cartão, diálogo) pausa comGamePause, e a UI usa tempo "unscaled" para continuar animando.
Fase 0 — Setup do Projeto
Objetivo desta fase: um projeto Unity 6 2D vazio, organizado, com os pacotes certos e pronto para build Web.
0.1 Criar o projeto
- Instale o Unity Hub e uma versão Unity 6 LTS (6000.x) com o módulo Web Build Support (o alvo é navegador, Parte 1 §1).
- No Hub, crie um projeto com New project → Universal 2D e o nome
CapibaribeVivo.- É aqui que o "2D topdown" da Parte 1 vira decisão técnica: o projeto é 2D. O template já vem com URP 2D, sprites, física 2D e câmera ortográfica.
- Topdown não muda o template. Em 2D, "topdown" é só não usar gravidade: o eixo Y da tela é "rio acima/rio abaixo", não "para cima/para baixo". Por isso todo
Rigidbody2Ddo jogo terá Gravity Scale = 0.
0.2 Estrutura de pastas
Crie dentro de Assets/:
Assets/_Project/
Scripts/
Core/ (pausa, save, narração, IClickable)
Data/ (GameConfig e os ...Def)
River/ (correnteza, cor da água, Rio Vivo)
Boat/ (barco, controle, puçá, porão)
Trash/
Sorting/ (Ecoponto e separação)
Fauna/
Sources/ (fontes de poluição)
Progress/ (trechos, mapa, álbum, personagens)
UI/
Resources/ (GameConfig e a pasta Album/)
ScriptableObjects/
Trash/
Stretches/
Characters/
Prefabs/
UI/
Scenes/ (Menu, Map, Stretch_01_Varzea, ...)
Sprites/
Audio/
Narration/
0.3 Pacotes (Window → Package Manager)
| Pacote | Para quê |
|---|---|
| Input System (já vem no Unity 6) | Ler clique do mouse e toque com a mesma conta (Pointer). |
| 2D Sprite (vem com o template 2D) | Sprites placeholder pelo menu Create → 2D → Sprites (círculo, quadrado). |
| 2D SpriteShape | Desenhar as margens do rio como curvas, com o collider gerado sozinho (Fase 1). Combina com a arte vetorial. |
| TextMeshPro (parte do pacote uGUI no Unity 6) | Textos da UI. No primeiro uso, aceite Import TMP Essentials. |
0.4 Camadas, física e input
- Layers (Edit → Project Settings → Tags and Layers → Layers): crie
Land,Boat,TrasheInteract. - Matriz de colisão (Project Settings → Physics 2D → Layer Collision Matrix): deixe ligado só:
Boat×Land,Boat×Trash,Boat×Interact,Trash×Land.- Desligue o resto (principalmente
Trash×Trash, para o lixo não se amontoar em pilhas, eTrash×Interact).
- Physics 2D → Gravity: deixe como está; cada
Rigidbody2Dusa Gravity Scale 0. - Sorting Layers:
Water,Banks,World,FX, nessa ordem. - Project Settings → Player → Other Settings → Active Input Handling: Input System Package (New) ou Both.
- Crie as cenas
Menu,MapeStretch_01_VarzeaemAssets/_Project/Scenes/e adicione as três em File → Build Profiles → Scene List, comMenuem primeiro.
✅ Checkpoint da Fase 0
- O projeto abre sem erros no Console.
- As três cenas estão na Scene List, com
Menuno topo. - As 4 layers existem e a matriz de colisão só tem as 4 combinações ligadas.
- Create → 2D → Sprite Shape aparece no menu (o pacote SpriteShape está instalado).
Problemas comuns
- Não aparece a plataforma Web no Build Profiles: o módulo Web Build Support não foi instalado. No Unity Hub: Installs → (sua versão) → Add modules.
UnityEngine.InputSystemnão encontrado nos scripts: o pacote Input System não está instalado, ou o Active Input Handling está em "Input Manager (Old)".
Próxima fase: O Rio e o Barco, com as margens, a água, o barco indo para onde se clica e a câmera seguindo.
Fase 1 — O Rio e o Barco
Objetivo desta fase: no trecho
Stretch_01_Varzea, clicar na água faz o barco ir até lá, as margens o seguram e a câmera o segue.
1.1 Peças comuns: IClickable e GamePause
Duas peças pequenas usadas no jogo inteiro. A primeira diz "dá para clicar em mim"; a segunda pausa o jogo sem que uma tela desfaça a pausa da outra.
// Caminho: Assets/_Project/Scripts/Core/IClickable.cs
namespace CapibaribeVivo.Core
{
// POR QUE: o clique na tela pode cair na água (o barco vai até lá) ou em
// algo usável (Ecoponto, fonte de poluição, um bicho). O controle do barco
// não deve conhecer cada uma dessas coisas.
// ESTRATÉGIA: uma "interface" é um contrato: qualquer componente que
// escreva ": IClickable" promete ter o método OnClicked. O controle só
// procura "alguém com IClickable" debaixo do clique e chama o método.
public interface IClickable
{
// Chamado quando a criança clica no objeto. Recebe o barco porque
// quase sempre a resposta é "barco, venha até aqui".
void OnClicked(Boat.BoatController boat);
}
}
// Caminho: Assets/_Project/Scripts/Core/GamePause.cs
using UnityEngine;
using UnityEngine.SceneManagement;
namespace CapibaribeVivo.Core
{
// POR QUE: várias telas pausam o jogo (separação, cartão de um bicho,
// diálogo) e às vezes uma abre por cima da outra. Se cada uma fizesse
// "timeScale = 1" ao fechar, a primeira a fechar despausaria o jogo com a
// outra ainda aberta.
// ESTRATÉGIA: um contador. Cada tela que abre faz Push (+1) e cada uma que
// fecha faz Pop (-1); o jogo só volta quando o contador chega a zero.
// Time.timeScale = 0 para a física e tudo que usa Time.deltaTime; a UI
// continua funcionando porque cliques não dependem do tempo.
public static class GamePause
{
private static int _count;
public static bool IsPaused => _count > 0;
// Uma tela abriu: pausa (ou continua pausado).
public static void Push()
{
_count++;
Apply();
}
// Uma tela fechou: só despausa se ela era a última.
public static void Pop()
{
_count = Mathf.Max(0, _count - 1);
Apply();
}
// Zera tudo. Usado ao trocar de cena, para nunca começar uma cena pausada.
public static void Reset()
{
_count = 0;
Apply();
}
private static void Apply() => Time.timeScale = _count > 0 ? 0f : 1f;
// [RuntimeInitializeOnLoadMethod] faz a Unity chamar este método sozinha
// ao apertar Play, sem precisar de um objeto na cena.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void Init()
{
_count = 0;
SceneManager.sceneLoaded += (_, _) => Reset();
}
}
}
1.2 Desenhar o rio
O rio é o espaço entre duas margens. A água é só um fundo atrás de tudo; quem segura o barco são as margens.
- Água: crie 2D Object → Sprites → Square, nome
Water, Sorting LayerWater, escala grande o bastante para cobrir o trecho inteiro (ex.: 80 × 30). Cor marrom-acinzentado (#6B6650) por enquanto: a Fase 4 faz ela clarear. - Margens: crie 2D Object → Sprite Shape → Closed Shape, nome
Bank_North, layerLand, Sorting LayerBanks.- Edite os pontos (botão Edit Spline no Inspector) para formar a margem de cima do rio, com o lado de dentro sendo a terra. Faça curvas: o Capibaribe é cheio delas.
- Adicione um Polygon Collider 2D. O
SpriteShapeControlleratualiza esse collider sozinho a partir da curva (no Inspector dele, em Collider, deixe Update Collider ligado). - Duplique para
Bank_South, a margem de baixo.
- Fim do trecho: feche as duas pontas do rio (rio acima e rio abaixo) com mais duas formas na layer
Land, para o barco e o lixo não saírem do trecho.
Dica: comece com um rio de uns 60 m de comprimento e 6 a 10 m de largura. Largo demais deixa a coleta fácil e sem graça; estreito demais faz a criança bater nas margens o tempo todo.
1.3 O barco: BoatController
// Caminho: Assets/_Project/Scripts/Boat/BoatController.cs
using UnityEngine;
namespace CapibaribeVivo.Boat
{
// POR QUE: o barco precisa ir até o ponto clicado de um jeito suave (acelera,
// desacelera ao chegar, vira de frente) e ser segurado pelas margens.
// ESTRATÉGIA: um Rigidbody2D (o componente que faz a Unity cuidar de
// colisão e movimento físico) que nunca recebe "teletransporte": a cada passo
// da física calculamos a velocidade desejada (em direção ao alvo, menor
// perto dele) e aproximamos a velocidade atual dela aos poucos. Como quem
// move é a física, as margens seguram o barco sozinhas.
[RequireComponent(typeof(Rigidbody2D))]
public class BoatController : MonoBehaviour
{
[SerializeField] private float _maxSpeed = 3f; // Parte 1 §4 (proposta)
[SerializeField] private float _acceleration = 6f; // m/s ganhos por segundo
[SerializeField] private float _slowRadius = 1.5f; // começa a frear a esta distância
[SerializeField] private float _arriveRadius = 0.25f;
[SerializeField] private float _turnSpeed = 360f; // graus por segundo
private Rigidbody2D _rb;
private Vector2? _target; // "Vector2?" pode ser nulo: sem alvo, o barco para.
public bool HasTarget => _target.HasValue;
// Awake roda uma vez, quando o objeto nasce, antes de qualquer Start.
private void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_rb.gravityScale = 0f; // topdown: nada "cai" para baixo da tela
}
// Define para onde o barco vai.
public void SetTarget(Vector2 point) => _target = point;
// Para o barco na hora (ex.: ao encostar no Ecoponto).
public void Stop()
{
_target = null;
_rb.linearVelocity = Vector2.zero;
}
// FixedUpdate roda no ritmo da física (50 vezes por segundo), o lugar
// certo para mexer em Rigidbody.
private void FixedUpdate()
{
Vector2 desired = Vector2.zero;
if (_target.HasValue)
{
Vector2 toTarget = _target.Value - _rb.position;
float distance = toTarget.magnitude;
if (distance < _arriveRadius)
{
_target = null; // chegou
}
else
{
// Longe: velocidade máxima. Dentro do _slowRadius: cai até zero.
float speed = _maxSpeed * Mathf.Clamp01(distance / _slowRadius);
desired = toTarget / distance * speed;
}
}
_rb.linearVelocity = Vector2.MoveTowards(
_rb.linearVelocity, desired, _acceleration * Time.fixedDeltaTime);
TurnTowardsVelocity();
}
// Gira o barco para a direção em que ele está andando. O sprite do barco
// é desenhado com a proa para cima, por isso o "- 90".
private void TurnTowardsVelocity()
{
Vector2 v = _rb.linearVelocity;
if (v.sqrMagnitude < 0.01f) return; // parado: mantém o ângulo
float targetAngle = Mathf.Atan2(v.y, v.x) * Mathf.Rad2Deg - 90f;
float angle = Mathf.MoveTowardsAngle(
_rb.rotation, targetAngle, _turnSpeed * Time.fixedDeltaTime);
_rb.MoveRotation(angle);
}
}
}
⚠️ Nota de versão:
rb.linearVelocityé o nome usado no Unity 6 / 2023.x+. Se o projeto rodar em 2022 LTS, troque porrb.velocity(mesma coisa, nome antigo). O mesmo vale para o campo Linear Damping do Inspector, que em 2022 LTS se chama Linear Drag.
1.4 O controle: ClickToMoveInput
// Caminho: Assets/_Project/Scripts/Boat/ClickToMoveInput.cs
using CapibaribeVivo.Core;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
namespace CapibaribeVivo.Boat
{
// POR QUE: o controle definido é "clicar para ir" (Parte 1 §4), que tem que
// funcionar com mouse agora e com toque depois.
// ESTRATÉGIA: Pointer.current é o "ponteiro" do Input System: o mouse no PC
// e o dedo no tablet, com a mesma API. No começo do clique decidimos o que
// ele é: um botão da UI (ignora), algo usável (chama OnClicked), terra
// (ignora) ou água (vira alvo do barco). Enquanto o botão segue apertado
// sobre a água, o alvo acompanha o cursor.
public class ClickToMoveInput : MonoBehaviour
{
[SerializeField] private BoatController _boat;
[SerializeField] private LayerMask _landMask; // layer Land
[SerializeField] private LayerMask _clickableMask; // layer Interact
[SerializeField] private Transform _targetMarker; // círculo na água (opcional)
private Camera _cam;
private bool _followingHold; // o clique atual começou na água?
private void Awake() => _cam = Camera.main;
// Update roda uma vez por quadro: o lugar certo para ler input.
private void Update()
{
var pointer = Pointer.current;
if (pointer == null || GamePause.IsPaused) return;
if (pointer.press.wasPressedThisFrame)
_followingHold = HandlePress(pointer);
if (!pointer.press.isPressed) _followingHold = false;
if (_followingHold)
{
Vector2 world = ToWorld(pointer);
if (!IsLand(world)) MoveTo(world);
}
}
// Decide o que o clique é. Devolve true se começou na água (aí segurar
// o botão continua guiando o barco).
private bool HandlePress(Pointer pointer)
{
// Clique em cima de um botão ou painel da UI: não é para o barco.
if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject())
return false;
Vector2 world = ToWorld(pointer);
Collider2D hit = Physics2D.OverlapPoint(world, _clickableMask);
if (hit != null)
{
// GetComponentInParent aceita interfaces: acha o IClickable no
// objeto do collider ou em algum pai dele.
var clickable = hit.GetComponentInParent<IClickable>();
if (clickable != null)
{
clickable.OnClicked(_boat);
return false;
}
}
if (IsLand(world)) return false; // Parte 1 §4: clicar em terra não faz nada
MoveTo(world);
return true;
}
private void MoveTo(Vector2 world)
{
_boat.SetTarget(world);
if (_targetMarker != null) _targetMarker.position = world;
}
private bool IsLand(Vector2 world) => Physics2D.OverlapPoint(world, _landMask) != null;
// Converte a posição do ponteiro (em pixels da tela) para o mundo do jogo.
private Vector2 ToWorld(Pointer pointer)
{
Vector2 screen = pointer.position.ReadValue();
return _cam.ScreenToWorldPoint(screen);
}
}
}
1.5 A câmera: CameraFollow2D
// Caminho: Assets/_Project/Scripts/River/CameraFollow2D.cs
using UnityEngine;
namespace CapibaribeVivo.River
{
// POR QUE: o trecho é maior que a tela, então a câmera segue o barco, mas
// não pode mostrar o "fora do mapa" depois do fim do rio.
// ESTRATÉGIA: a cada quadro, depois que tudo se moveu (LateUpdate), a
// câmera desliza suave (SmoothDamp) até o barco e é presa dentro de um
// retângulo. Se o retângulo for menor que a tela num eixo, centraliza.
[RequireComponent(typeof(Camera))]
public class CameraFollow2D : MonoBehaviour
{
[SerializeField] private Transform _target;
[SerializeField] private float _smoothTime = 0.3f;
[SerializeField] private Rect _bounds = new Rect(-30f, -12f, 60f, 24f);
private Camera _cam;
private Vector3 _velocity; // usado pelo SmoothDamp para lembrar a velocidade
private void Awake() => _cam = GetComponent<Camera>();
private void LateUpdate()
{
if (_target == null) return;
Vector3 goal = _target.position;
goal.z = transform.position.z; // a câmera 2D fica sempre no mesmo Z
float halfH = _cam.orthographicSize;
float halfW = halfH * _cam.aspect;
goal.x = ClampAxis(goal.x, _bounds.xMin, _bounds.xMax, halfW);
goal.y = ClampAxis(goal.y, _bounds.yMin, _bounds.yMax, halfH);
transform.position = Vector3.SmoothDamp(transform.position, goal, ref _velocity, _smoothTime);
}
// Prende um eixo entre min e max, contando a metade da tela.
private static float ClampAxis(float value, float min, float max, float half)
{
if (max - min < half * 2f) return (min + max) * 0.5f;
return Mathf.Clamp(value, min + half, max - half);
}
// Desenha o retângulo na janela Scene, para ajustar à mão.
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.cyan;
Gizmos.DrawWireCube(_bounds.center, _bounds.size);
}
}
}
1.6 A correnteza: RiverFlow
O lixo da Fase 2 deriva rio abaixo. A correnteza fica num componente da cena, porque cada trecho corre para um lado.
// Caminho: Assets/_Project/Scripts/River/RiverFlow.cs
using UnityEngine;
namespace CapibaribeVivo.River
{
// POR QUE: o lixo deriva devagar rio abaixo (Parte 1 §5), e cada trecho tem
// a sua direção de correnteza.
// ESTRATÉGIA: uma correnteza única por trecho, guardada num campo
// "static" (que pertence à classe, não a um objeto) para qualquer lixo ler
// sem precisar procurar o componente. É uma simplificação: o rio faz curvas
// e a correnteza não. As margens seguram o lixo nas curvas.
public class RiverFlow : MonoBehaviour
{
public static Vector2 Current { get; private set; }
[SerializeField] private Vector2 _direction = Vector2.right; // para onde a água corre
[SerializeField] private float _strength = 0.15f; // fraca de propósito
private void OnEnable() => Current = _direction.normalized * _strength;
private void OnDisable() => Current = Vector2.zero;
}
}
1.7 Montar a cena
- Barco: crie um objeto
Boat, layerBoat, Sorting LayerWorld, com:- Sprite Renderer com um placeholder (um retângulo alongado, proa para cima).
- Rigidbody2D: Body Type Dynamic, Gravity Scale 0, Linear Damping 1, Angular Damping 5, Collision Detection Continuous, Interpolate Interpolate (deixa o movimento liso entre os passos da física).
- Capsule Collider 2D (vertical) do tamanho do casco.
BoatController.
- Câmera: na Main Camera, Projection = Orthographic, Size = 6, e adicione
CameraFollow2Dcom oBoatcomo alvo. Ajuste o retângulo para cobrir o trecho (o gizmo ciano mostra). - Controle: crie um objeto vazio
InputcomClickToMoveInput: arraste oBoat, marqueLandem Land Mask eInteractem Clickable Mask. Para o marcador, um círculo pequeno e semitransparente. - EventSystem: GameObject → UI → Event System (o
IsPointerOverGameObjectprecisa dele). Se aparecer o aviso de módulo antigo, clique em Replace with InputSystemUIInputModule. - Correnteza: um objeto vazio
RivercomRiverFlow, direção apontando rio abaixo.
✅ Checkpoint da Fase 1
- Clicar na água faz o barco ir até o ponto, frear e parar nele, virado para onde andava.
- Segurar o botão e arrastar faz o barco seguir o cursor.
- Clicar em terra não faz nada; o barco não atravessa as margens nem sai pelas pontas do trecho.
- A câmera segue o barco e nunca mostra a área fora do retângulo.
Problemas comuns
- O barco atravessa a margem: o Polygon Collider 2D da Sprite Shape não foi gerado (veja Update Collider no
SpriteShapeController) ou a margem não está na layerLand. Com o barco rápido, confirme Collision Detection = Continuous. - O barco gira sozinho ao bater na margem: aumente o Angular Damping, ou marque Constraints → Freeze Rotation Z (o
MoveRotationcontinua girando o barco). - Clicar na água não faz nada: a água (
Water) tem um collider na layerLand? Ela não deve ter collider nenhum.
Próxima fase: Lixo e Coleta, com o lixo boiando, o puçá recolhendo e o porão.
Fase 2 — Lixo e Coleta
Objetivo desta fase: o rio começa sujo, o barco recolhe o lixo ao passar perto e o porão enche até 8, com aviso e seta para o Ecoponto.
2.1 Os dados: GameConfig e TrashItemDef
Um ScriptableObject é um arquivo de dados que vive na pasta do projeto (não na cena) e aparece no Inspector. É onde ficam os números de proposta da Parte 1 e cada item de lixo.
// Caminho: Assets/_Project/Scripts/Data/TrashItemDef.cs
using UnityEngine;
namespace CapibaribeVivo.Data
{
// Os 6 tipos de lixo da Parte 1 §5 (cada um vai numa cor de lixeira).
public enum TrashCategory { Paper, Plastic, Glass, Metal, Organic, NonRecyclable }
// POR QUE: cada item de lixo (garrafa PET, latinha, baronesa...) tem nome,
// ícone, tipo e a frase "Você sabia?". Isso é dado, não comportamento, e o
// time de conteúdo precisa editar sem mexer em código.
// ESTRATÉGIA: um ScriptableObject por item. [CreateAssetMenu] cria a opção
// no menu Create do Project, então criar um item novo é clicar e preencher.
[CreateAssetMenu(menuName = "Capibaribe Vivo/Trash Item", fileName = "Trash_")]
public class TrashItemDef : ScriptableObject
{
public string displayName = "Garrafa PET";
public Sprite icon;
public TrashCategory category;
[TextArea(2, 5)] public string fact = "Uma garrafa PET pode virar fio para fazer roupa.";
public AudioClip factNarration; // a mesma frase, falada (Parte 1 §16)
}
}
// Caminho: Assets/_Project/Scripts/Data/GameConfig.cs
using System.Collections.Generic;
using UnityEngine;
namespace CapibaribeVivo.Data
{
// POR QUE: os números de proposta da Parte 1 (porão, pontos, teto das
// fontes) vão mudar muito no teste com crianças. Ficando num lugar só, o
// balanceamento é editar um asset.
// ESTRATÉGIA: um único asset chamado "GameConfig" dentro de uma pasta
// "Resources". Tudo que fica em Resources pode ser carregado pelo nome com
// Resources.Load, então qualquer script acha a configuração sem precisar
// arrastar referência no Inspector.
[CreateAssetMenu(menuName = "Capibaribe Vivo/Game Config", fileName = "GameConfig")]
public class GameConfig : ScriptableObject
{
[Header("Barco (Parte 1 §4)")]
public int cargoCapacity = 8;
[Header("Pontos do Rio Vivo (Parte 1 §6, §7)")]
public int pointsPerPickup = 1;
public int pointsPerCorrectSort = 2; // acertou na primeira vez
public int pointsPerLateSort = 1; // acertou depois de errar
public int pointsPerSourceClosed = 15;
[Range(0f, 1f)] public float openSourcesCeilingPenalty = 0.4f; // todas abertas = teto de 60%
[Header("Cenas")]
public string menuScene = "Menu";
public string mapScene = "Map";
[Header("Conteúdo (preenchido nas Fases 6 e 7)")]
public List<StretchDef> stretches = new(); // na ordem do mapa
public List<CharacterDef> characters = new();
private static GameConfig _instance;
// Acha o asset na primeira vez que alguém pede e guarda para as próximas.
public static GameConfig Instance
{
get
{
if (_instance == null) _instance = Resources.Load<GameConfig>("GameConfig");
return _instance;
}
}
}
}
StretchDefeCharacterDefsó são escritos nas Fases 6 e 7. Até lá, para compilar, crie os dois como classes vazias (public class StretchDef : ScriptableObject {}epublic class CharacterDef : ScriptableObject {}, no namespaceCapibaribeVivo.Data) e substitua depois.
Crie o asset em Assets/_Project/Resources/ com Create → Capibaribe Vivo → Game Config, com o nome exato GameConfig. Crie também os itens da tabela da Parte 1 §5 em ScriptableObjects/Trash/ (pode começar com um de cada tipo).
2.2 O lixo boiando: FloatingTrash
// Caminho: Assets/_Project/Scripts/Trash/FloatingTrash.cs
using System.Collections.Generic;
using CapibaribeVivo.Data;
using CapibaribeVivo.River;
using UnityEngine;
namespace CapibaribeVivo.Trash
{
// POR QUE: o lixo precisa boiar (balançar), derivar rio abaixo e bater nas
// margens, e precisa lembrar que item é e de qual fonte veio (a fonte só
// manda lixo novo se tiver pouco do dela no rio, Fase 5).
// ESTRATÉGIA: um Rigidbody2D leve empurrado pela correnteza com AddForce,
// com "damping" alto para nunca ganhar velocidade. O balanço é só visual:
// um filho com o sprite sobe e desce, sem mexer na física. Uma lista
// estática "All" guarda todo lixo vivo, para contar sem procurar na cena.
[RequireComponent(typeof(Rigidbody2D))]
public class FloatingTrash : MonoBehaviour
{
public static readonly List<FloatingTrash> All = new();
[SerializeField] private TrashItemDef _item; // para lixo colocado à mão na cena
[SerializeField] private SpriteRenderer _visual; // filho com o sprite
[SerializeField] private float _bobHeight = 0.05f;
[SerializeField] private float _wobble = 0.05f; // empurrãozinho aleatório
private Rigidbody2D _rb;
private float _phase;
public TrashItemDef Item => _item;
public Object Source { get; private set; } // a PollutionSource de origem (ou nada)
private void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_rb.gravityScale = 0f;
_phase = Random.value * 10f; // cada lixo balança num ritmo diferente
}
// Chamado por quem cria o lixo (espalhamento inicial ou fonte).
public void Init(TrashItemDef item, Object source)
{
_item = item;
Source = source;
ApplyVisual();
}
private void Start()
{
ApplyVisual();
_visual.transform.localRotation = Quaternion.Euler(0, 0, Random.Range(0f, 360f));
}
// OnEnable/OnDisable rodam quando o objeto liga/desliga (e ao ser destruído).
private void OnEnable() => All.Add(this);
private void OnDisable() => All.Remove(this);
private void ApplyVisual()
{
if (_item != null && _visual != null) _visual.sprite = _item.icon;
}
private void FixedUpdate()
{
Vector2 wobble = Random.insideUnitCircle * _wobble;
_rb.AddForce(RiverFlow.Current + wobble);
}
private void Update()
{
// Sobe e desce devagar, só o desenho.
float y = Mathf.Sin(Time.time * 2f + _phase) * _bobHeight;
_visual.transform.localPosition = new Vector3(0f, y, 0f);
}
}
}
2.3 O rio começa sujo: TrashScatter
// Caminho: Assets/_Project/Scripts/Trash/TrashScatter.cs
using CapibaribeVivo.Data;
using UnityEngine;
namespace CapibaribeVivo.Trash
{
// POR QUE: colocar 30 lixos à mão em cada trecho é lento e chato de ajustar.
// ESTRATÉGIA: sorteia posições dentro de um retângulo e descarta as que
// caem em terra (Physics2D.OverlapCircle na layer Land). Com uma "semente"
// (seed) diferente de zero, o sorteio é sempre o mesmo, o que ajuda a
// testar; com zero, muda a cada partida.
public class TrashScatter : MonoBehaviour
{
[SerializeField] private FloatingTrash _prefab;
[SerializeField] private TrashItemDef[] _pool;
[SerializeField] private int _count = 30;
[SerializeField] private Rect _area = new Rect(-28f, -5f, 56f, 10f);
[SerializeField] private LayerMask _landMask;
[SerializeField] private float _clearance = 0.4f; // distância mínima da margem
[SerializeField] private int _seed;
public int Count => _count;
private void Start()
{
var rng = _seed == 0 ? new System.Random() : new System.Random(_seed);
int placed = 0;
int tries = 0;
while (placed < _count && tries < _count * 30)
{
tries++;
var p = new Vector2(
Mathf.Lerp(_area.xMin, _area.xMax, (float)rng.NextDouble()),
Mathf.Lerp(_area.yMin, _area.yMax, (float)rng.NextDouble()));
if (Physics2D.OverlapCircle(p, _clearance, _landMask) != null) continue;
FloatingTrash trash = Instantiate(_prefab, p, Quaternion.identity, transform);
trash.Init(_pool[rng.Next(_pool.Length)], null);
placed++;
}
if (placed < _count)
Debug.LogWarning($"TrashScatter: só coube {placed} de {_count}. Aumente a área.", this);
}
private void OnDrawGizmosSelected()
{
Gizmos.color = new Color(1f, 0.6f, 0f);
Gizmos.DrawWireCube(_area.center, _area.size);
}
}
}
2.4 O porão e o puçá: BoatCargo e BoatNet
// Caminho: Assets/_Project/Scripts/Boat/BoatCargo.cs
using System;
using System.Collections.Generic;
using CapibaribeVivo.Data;
using UnityEngine;
namespace CapibaribeVivo.Boat
{
// POR QUE: o lixo recolhido fica guardado até a separação (Parte 1 §4), e o
// HUD, a seta e o Ecoponto precisam saber o que tem lá.
// ESTRATÉGIA: uma lista de TrashItemDef (o dado do item, não o objeto da
// cena, que é destruído ao recolher). Quem quiser saber de mudanças assina
// o evento Changed (um "evento" é uma lista de funções chamadas juntas).
public class BoatCargo : MonoBehaviour
{
public event Action Changed;
public event Action BumpedWhileFull; // tentou recolher com o porão cheio
private readonly List<TrashItemDef> _items = new();
private float _lastBump = -10f;
public IReadOnlyList<TrashItemDef> Items => _items;
public int Capacity => GameConfig.Instance.cargoCapacity;
public bool IsFull => _items.Count >= Capacity;
// Tenta guardar um item. Devolve false se o porão está cheio.
public bool TryAdd(TrashItemDef item)
{
if (IsFull)
{
// Avisa no máximo a cada 3 s, senão o aviso pisca sem parar.
if (Time.time - _lastBump > 3f)
{
_lastBump = Time.time;
BumpedWhileFull?.Invoke();
}
return false;
}
_items.Add(item);
Changed?.Invoke();
return true;
}
// Esvazia o porão e entrega tudo (o Ecoponto chama isto).
public List<TrashItemDef> TakeAll()
{
var all = new List<TrashItemDef>(_items);
_items.Clear();
Changed?.Invoke();
return all;
}
}
}
// Caminho: Assets/_Project/Scripts/Boat/BoatNet.cs
using CapibaribeVivo.Data;
using CapibaribeVivo.River;
using CapibaribeVivo.Trash;
using UnityEngine;
namespace CapibaribeVivo.Boat
{
// POR QUE: o puçá recolhe sozinho o lixo que passa perto (Parte 1 §4), sem a
// criança precisar mirar.
// ESTRATÉGIA: um collider "trigger" (que não bate, só avisa quando algo
// entra nele) num filho do barco. OnTriggerStay2D é chamado enquanto o lixo
// está dentro, então, se o porão esvaziar com o barco em cima do lixo, ele
// é recolhido na hora.
public class BoatNet : MonoBehaviour
{
[SerializeField] private BoatCargo _cargo;
private void OnTriggerStay2D(Collider2D other)
{
var trash = other.GetComponentInParent<FloatingTrash>();
if (trash == null || trash.Item == null) return;
if (_cargo.TryAdd(trash.Item))
{
// RiverHealth vem na Fase 4. O "?." pula a chamada se ainda não existir.
RiverHealth.Instance?.AddPoints(GameConfig.Instance.pointsPerPickup);
Destroy(trash.gameObject);
}
}
}
}
RiverHealthsó é escrito na Fase 4. Até lá, comente a linha doRiverHealthpara compilar.
2.5 Avisos e HUD do porão: Toast, CargoHud e CargoFullHint
// Caminho: Assets/_Project/Scripts/UI/Toast.cs
using System.Collections;
using TMPro;
using UnityEngine;
namespace CapibaribeVivo.UI
{
// POR QUE: avisos curtos ("Porão cheio!", "A garça voltou!") precisam
// aparecer sem pausar nem exigir clique.
// ESTRATÉGIA: um texto com CanvasGroup (que controla a transparência de um
// pedaço da UI) que aparece, espera e some. Usa tempo "unscaled" para
// funcionar mesmo com o jogo pausado. Um aviso novo substitui o anterior.
[RequireComponent(typeof(CanvasGroup))]
public class Toast : MonoBehaviour
{
public static Toast Instance { get; private set; }
[SerializeField] private TMP_Text _text;
[SerializeField] private float _duration = 2.5f;
private CanvasGroup _group;
private Coroutine _routine;
private void Awake()
{
Instance = this;
_group = GetComponent<CanvasGroup>();
_group.alpha = 0f;
_group.blocksRaycasts = false; // nunca atrapalha cliques
}
// Atalho para qualquer script: Toast.Show("...").
public static void Show(string message)
{
if (Instance != null) Instance.ShowInternal(message);
}
private void ShowInternal(string message)
{
_text.text = message;
if (_routine != null) StopCoroutine(_routine);
_routine = StartCoroutine(Run());
}
// Uma "coroutine" é uma função que pode esperar no meio (yield) sem travar o jogo.
private IEnumerator Run()
{
yield return Fade(0f, 1f, 0.2f);
yield return new WaitForSecondsRealtime(_duration);
yield return Fade(1f, 0f, 0.4f);
}
private IEnumerator Fade(float from, float to, float time)
{
for (float t = 0f; t < time; t += Time.unscaledDeltaTime)
{
_group.alpha = Mathf.Lerp(from, to, t / time);
yield return null; // espera o próximo quadro
}
_group.alpha = to;
}
}
}
// Caminho: Assets/_Project/Scripts/UI/CargoHud.cs
using CapibaribeVivo.Boat;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.UI
{
// POR QUE: a criança precisa ver o porão enchendo, com os ícones do que
// recolheu (Parte 1 §15), e ser avisada quando encher.
// ESTRATÉGIA: uma fileira fixa de Images (uma por vaga do porão). A cada
// mudança, as primeiras recebem o ícone e as outras ficam vazias.
public class CargoHud : MonoBehaviour
{
[SerializeField] private BoatCargo _cargo;
[SerializeField] private Image[] _slots; // tantas quanto o porão (8)
[SerializeField] private Sprite _emptySlot;
[SerializeField] private TMP_Text _count; // "3/8"
private void OnEnable()
{
_cargo.Changed += Refresh;
_cargo.BumpedWhileFull += OnFull;
}
private void OnDisable()
{
_cargo.Changed -= Refresh;
_cargo.BumpedWhileFull -= OnFull;
}
private void Start() => Refresh();
private void Refresh()
{
var items = _cargo.Items;
for (int i = 0; i < _slots.Length; i++)
_slots[i].sprite = i < items.Count ? items[i].icon : _emptySlot;
_count.text = $"{items.Count}/{_cargo.Capacity}";
}
private void OnFull() => Toast.Show("Porão cheio! Leve ao Ecoponto.");
}
}
// Caminho: Assets/_Project/Scripts/Boat/CargoFullHint.cs
using UnityEngine;
namespace CapibaribeVivo.Boat
{
// POR QUE: com o porão cheio, uma seta perto do barco aponta para o
// Ecoponto mais perto (Parte 1 §4), para a criança não ficar perdida.
// ESTRATÉGIA: o Ecoponto ainda não existe (Fase 3), então a seta aponta
// para qualquer Transform marcado como destino. Na Fase 3, os Ecopontos se
// registram aqui. A seta é reposicionada em LateUpdate, em coordenadas do
// mundo, para não girar junto com o barco.
public class CargoFullHint : MonoBehaviour
{
public static readonly System.Collections.Generic.List<Transform> Targets = new();
[SerializeField] private BoatCargo _cargo;
[SerializeField] private Transform _arrow;
[SerializeField] private float _distance = 1.2f;
private void LateUpdate()
{
Transform nearest = FindNearest();
bool show = _cargo.IsFull && nearest != null;
_arrow.gameObject.SetActive(show);
if (!show) return;
Vector2 dir = ((Vector2)nearest.position - (Vector2)transform.position).normalized;
_arrow.position = (Vector2)transform.position + dir * _distance;
_arrow.rotation = Quaternion.FromToRotation(Vector3.up, dir); // sprite da seta aponta para cima
}
private Transform FindNearest()
{
Transform best = null;
float bestSq = float.MaxValue;
foreach (var t in Targets)
{
float sq = ((Vector2)(t.position - transform.position)).sqrMagnitude;
if (sq < bestSq) { bestSq = sq; best = t; }
}
return best;
}
}
}
2.6 Montar a cena
- Prefab do lixo (
Prefabs/FloatingTrash): objeto na layerTrashcom Rigidbody2D (Dynamic, Gravity Scale 0, Linear Damping 3, Angular Damping 3, Mass 0.2), Circle Collider 2D (raio 0.2) eFloatingTrash. Um filhoVisualcom o Sprite Renderer (Sorting LayerWorld), arrastado em Visual. - Espalhamento: objeto
TrashScattercom o prefab, a lista de itens, a área cobrindo o rio (gizmo laranja) e Land Mask =Land. - Puçá: filho
NetdoBoat, na layerBoat, com Circle Collider 2D marcado Is Trigger (raio 0.6, Parte 1 §4) eBoatNetapontando para oBoatCargo. - Porão:
BoatCargonoBoat.CargoFullHinttambém noBoat, com um filhoArrow(uma seta, Sorting LayerFX). Para testar sem Ecoponto, crie um objeto vazio na margem e, num script de teste, adicione-o emCargoFullHint.Targets. - HUD: GameObject → UI → Canvas (
HUD), Canvas Scaler → Scale With Screen Size, referência 1920 × 1080. Dentro:Cargo: um painel com 8 Images e um texto, comCargoHud.Toast: um texto grande no meio da tela com CanvasGroup eToast.
✅ Checkpoint da Fase 2
- O rio começa com uns 30 lixos espalhados na água, nenhum em terra, todos balançando e derivando devagar.
- Passar perto de um lixo recolhe e o ícone dele aparece no HUD do porão ("1/8").
- Com 8 itens, o lixo para de ser recolhido (o barco empurra), aparece "Porão cheio! Leve ao Ecoponto." e a seta aparece.
- O lixo bate nas margens e não sai do trecho.
Problemas comuns
- O puçá não recolhe nada: a matriz de colisão está com
Boat×Trashdesligado, ou o collider doNetnão está como Is Trigger. - O lixo dispara para longe ao nascer: dois lixos nasceram um em cima do outro com
Trash×Trashligado. Desligue na matriz (Fase 0 §0.4). GameConfig.Instanceé nulo: o asset não está em uma pasta chamadaResources, ou não se chama exatamenteGameConfig.
Próxima fase: Ecoponto e Separação, onde o lixo do porão é arrastado para as lixeiras.
Fase 3 — Ecoponto e Separação
Objetivo desta fase: encostar no Ecoponto abre a tela de separação, com o jogo pausado; cada item arrastado para a lixeira certa some, e o errado volta com um cartão explicando.
3.1 A voz: Narrator
A separação já precisa tocar a frase de cada item, então a narração entra aqui.
// Caminho: Assets/_Project/Scripts/Core/Narrator.cs
using UnityEngine;
namespace CapibaribeVivo.Core
{
// POR QUE: toda frase importante é narrada (Parte 1 §16), e uma narração
// nova deve cortar a anterior (não duas vozes ao mesmo tempo). O botão
// "ouvir de novo" precisa lembrar a última.
// ESTRATÉGIA: um único objeto que sobrevive à troca de cenas
// (DontDestroyOnLoad), criado sozinho quando o jogo começa. Ninguém precisa
// colocá-lo em cena nenhuma.
public class Narrator : MonoBehaviour
{
public static Narrator Instance { get; private set; }
private AudioSource _source;
private AudioClip _last;
// Cria o narrador ao apertar Play, antes da primeira cena rodar.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Create()
{
var go = new GameObject("Narrator");
go.AddComponent<Narrator>();
}
private void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject);
_source = gameObject.AddComponent<AudioSource>();
_source.playOnAwake = false;
_source.ignoreListenerPause = true;
}
// Toca uma narração, cortando a que estiver tocando. Sem clip, não faz nada.
public void Play(AudioClip clip)
{
if (clip == null) return;
_last = clip;
_source.Stop();
_source.clip = clip;
_source.Play();
}
public void RepeatLast() => Play(_last);
public void Stop() => _source.Stop();
}
}
3.2 O cartão: InfoCardPanel
Um cartão com imagem, título, texto e um botão, usado para o erro da separação, os bichos, o postal e o Álbum. Como dois cartões podem ser pedidos ao mesmo tempo (um erro e um bicho que acabou de voltar), ele tem fila.
// Caminho: Assets/_Project/Scripts/UI/InfoCardPanel.cs
using System;
using System.Collections.Generic;
using CapibaribeVivo.Core;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.UI
{
// POR QUE: o jogo mostra o mesmo tipo de cartão em muitos lugares (erro,
// fauna, postal, Álbum). Um só painel evita quatro telas quase iguais.
// ESTRATÉGIA: pedidos vão para uma fila (Queue); o painel mostra um de cada
// vez e só despausa o jogo quando a fila esvazia. O painel tem um fundo
// escuro de tela inteira que bloqueia cliques no jogo atrás.
public class InfoCardPanel : MonoBehaviour
{
public static InfoCardPanel Instance { get; private set; }
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text _title;
[SerializeField] private TMP_Text _body;
[SerializeField] private Image _image;
[SerializeField] private Button _button;
[SerializeField] private TMP_Text _buttonLabel;
// Um pedido de cartão: o que mostrar e o que fazer ao fechar.
private struct Request
{
public string title, body, buttonLabel;
public Sprite image;
public AudioClip narration;
public Action onClose;
}
private readonly Queue<Request> _queue = new();
private Request _current;
private bool _showing;
public bool IsOpen => _showing;
private void Awake()
{
Instance = this;
_root.SetActive(false);
_button.onClick.AddListener(CloseCurrent);
}
// Pede um cartão. Se já houver um aberto, entra na fila.
public void Show(string title, string body, Sprite image, AudioClip narration,
string buttonLabel = "OK", Action onClose = null)
{
_queue.Enqueue(new Request
{
title = title, body = body, image = image, narration = narration,
buttonLabel = buttonLabel, onClose = onClose,
});
if (!_showing) ShowNext();
}
private void ShowNext()
{
if (_queue.Count == 0)
{
_showing = false;
_root.SetActive(false);
GamePause.Pop();
return;
}
if (!_showing) GamePause.Push(); // pausa uma vez para a fila inteira
_showing = true;
_current = _queue.Dequeue();
_title.text = _current.title;
_body.text = _current.body;
_image.sprite = _current.image;
_image.gameObject.SetActive(_current.image != null);
_buttonLabel.text = _current.buttonLabel;
_root.SetActive(true);
transform.SetAsLastSibling(); // o painel inteiro passa para o fim do Canvas: fica por cima da separação
Narrator.Instance?.Play(_current.narration);
}
private void CloseCurrent()
{
Narrator.Instance?.Stop();
Action callback = _current.onClose;
ShowNext();
callback?.Invoke();
}
}
}
3.3 A tela de separação: SortingPanel, TrashCard e RecycleBin
A Unity tem um sistema pronto de arrastar na UI: um componente que implementa IBeginDragHandler, IDragHandler e IEndDragHandler recebe o arraste, e um que implementa IDropHandler recebe o "soltei em cima de você". A ordem é: OnDrop na lixeira primeiro, OnEndDrag no item depois.
// Caminho: Assets/_Project/Scripts/Sorting/TrashCard.cs
using CapibaribeVivo.Data;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace CapibaribeVivo.Sorting
{
// POR QUE: cada item do porão vira uma carta arrastável na bandeja.
// ESTRATÉGIA: ao começar o arraste, a carta sai da bandeja e vai para a raiz
// do Canvas (para ficar por cima de tudo) e para de bloquear cliques
// (blocksRaycasts = false), senão a lixeira embaixo dela nunca receberia o
// OnDrop. Se ninguém a aceitar, ela volta para a bandeja e o Layout Group
// da bandeja a coloca no lugar.
[RequireComponent(typeof(CanvasGroup))]
public class TrashCard : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _label;
private SortingPanel _panel;
private CanvasGroup _group;
private RectTransform _rect;
private bool _consumed;
public TrashItemDef Item { get; private set; }
public bool HadMistake { get; private set; }
public void Init(TrashItemDef item, SortingPanel panel)
{
Item = item;
_panel = panel;
_group = GetComponent<CanvasGroup>();
_rect = (RectTransform)transform;
_icon.sprite = item.icon;
_label.text = item.displayName;
}
public void OnBeginDrag(PointerEventData eventData)
{
_group.blocksRaycasts = false;
transform.SetParent(_panel.DragLayer, true);
transform.SetAsLastSibling();
}
public void OnDrag(PointerEventData eventData)
{
// eventData.delta vem em pixels da tela; o Canvas Scaler muda a escala.
_rect.anchoredPosition += eventData.delta / _panel.Canvas.scaleFactor;
}
public void OnEndDrag(PointerEventData eventData)
{
if (_consumed) return; // foi aceita por uma lixeira e vai sumir
_group.blocksRaycasts = true;
transform.SetParent(_panel.Tray, false);
}
public void Consume() => _consumed = true;
public void MarkMistake() => HadMistake = true;
}
}
// Caminho: Assets/_Project/Scripts/Sorting/RecycleBin.cs
using System;
using System.Collections;
using CapibaribeVivo.Data;
using UnityEngine;
using UnityEngine.EventSystems;
namespace CapibaribeVivo.Sorting
{
// POR QUE: cada lixeira colorida aceita um ou mais tipos de lixo.
// ESTRATÉGIA: a lixeira guarda uma LISTA de tipos aceitos em vez de um só.
// Assim o modo de 3 lixeiras (Parte 1 §5, em aberto) é só montar 3 lixeiras
// com listas maiores (ex.: "reciclável" aceita papel, plástico, vidro e
// metal), sem mudar código.
public class RecycleBin : MonoBehaviour, IDropHandler
{
[SerializeField] private string _label = "lixeira vermelha, do plástico";
[SerializeField] private TrashCategory[] _accepts = { TrashCategory.Plastic };
[SerializeField] private SortingPanel _panel;
private Coroutine _pulse;
public string Label => _label;
public bool Accepts(TrashCategory category) => Array.IndexOf(_accepts, category) >= 0;
// A Unity chama isto quando algo é solto em cima da lixeira.
public void OnDrop(PointerEventData eventData)
{
var card = eventData.pointerDrag != null ? eventData.pointerDrag.GetComponent<TrashCard>() : null;
if (card != null) _panel.HandleDrop(card, this);
}
// Pulinho de acerto.
public void PlayCorrect() => Pulse(1.15f, 1);
// Pisca três vezes: "o certo era aqui".
public void Highlight() => Pulse(1.3f, 3);
private void Pulse(float scale, int times)
{
if (_pulse != null) StopCoroutine(_pulse);
_pulse = StartCoroutine(PulseRoutine(scale, times));
}
private IEnumerator PulseRoutine(float scale, int times)
{
const float half = 0.12f;
for (int i = 0; i < times; i++)
{
for (float t = 0f; t < half * 2f; t += Time.unscaledDeltaTime)
{
float k = 1f - Mathf.Abs(t / half - 1f); // sobe e desce: 0 → 1 → 0
transform.localScale = Vector3.one * Mathf.Lerp(1f, scale, k);
yield return null;
}
}
transform.localScale = Vector3.one;
}
}
}
// Caminho: Assets/_Project/Scripts/Sorting/SortingPanel.cs
using System;
using System.Collections.Generic;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using CapibaribeVivo.River;
using CapibaribeVivo.UI;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.Sorting
{
// POR QUE: é aqui que a reciclagem é ensinada (Parte 1 §6): arrastar cada
// item para a lixeira certa, com explicação quando erra e sem punição.
// ESTRATÉGIA: o painel cria uma TrashCard por item do porão e decide cada
// soltura. Acerto: pontos, pulinho e a frase "Você sabia?" num balão (sem
// pausar). Erro: cartão explicando (InfoCardPanel) e a lixeira certa pisca.
// O jogo fica pausado do abrir ao fechar.
public class SortingPanel : MonoBehaviour
{
public static SortingPanel Instance { get; private set; }
[SerializeField] private GameObject _root;
[SerializeField] private Canvas _canvas;
[SerializeField] private RectTransform _tray; // tem um Grid Layout Group
[SerializeField] private RectTransform _dragLayer; // filho do painel, por cima de tudo
[SerializeField] private TrashCard _cardPrefab;
[SerializeField] private RecycleBin[] _bins;
[SerializeField] private TMP_Text _factText; // balão "Você sabia?"
[SerializeField] private Button _doneButton; // "Voltar ao rio"
private readonly List<TrashCard> _cards = new();
private Action _onClosed;
public Canvas Canvas => _canvas;
public RectTransform Tray => _tray;
public RectTransform DragLayer => _dragLayer;
public bool IsOpen => _root.activeSelf;
private void Awake()
{
Instance = this;
_root.SetActive(false);
_doneButton.onClick.AddListener(Close);
}
// Abre a separação com os itens do porão.
public void Open(List<TrashItemDef> items, Action onClosed = null)
{
_onClosed = onClosed;
_root.SetActive(true);
GamePause.Push();
foreach (var item in items)
{
TrashCard card = Instantiate(_cardPrefab, _tray);
card.Init(item, this);
_cards.Add(card);
}
_factText.text = "Arraste cada lixo para a lixeira certa!";
_doneButton.gameObject.SetActive(false);
}
// Chamado pela lixeira quando uma carta é solta nela.
public void HandleDrop(TrashCard card, RecycleBin bin)
{
var config = GameConfig.Instance;
if (bin.Accepts(card.Item.category))
{
int points = card.HadMistake ? config.pointsPerLateSort : config.pointsPerCorrectSort;
RiverHealth.Instance?.AddPoints(points);
bin.PlayCorrect();
_factText.text = card.Item.fact;
Narrator.Instance?.Play(card.Item.factNarration);
card.Consume();
_cards.Remove(card);
Destroy(card.gameObject);
if (_cards.Count == 0)
{
_factText.text = "Tudo separado! O rio agradece.";
_doneButton.gameObject.SetActive(true);
}
return;
}
card.MarkMistake();
RecycleBin right = FindBinFor(card.Item.category);
right?.Highlight();
string where = right != null ? right.Label : "outra lixeira";
InfoCardPanel.Instance.Show(
"Ops!",
$"{card.Item.displayName} vai na {where}.\n\n{card.Item.fact}",
card.Item.icon,
card.Item.factNarration);
}
private RecycleBin FindBinFor(TrashCategory category)
{
foreach (var bin in _bins)
if (bin.Accepts(category)) return bin;
return null;
}
private void Close()
{
_root.SetActive(false);
GamePause.Pop();
Action callback = _onClosed;
_onClosed = null;
callback?.Invoke();
}
}
}
RiverHealthvem na Fase 4: até lá, comente a linha dele também aqui.
3.4 O Ecoponto: Ecopoint
// Caminho: Assets/_Project/Scripts/Sorting/Ecopoint.cs
using CapibaribeVivo.Boat;
using CapibaribeVivo.Core;
using CapibaribeVivo.UI;
using UnityEngine;
namespace CapibaribeVivo.Sorting
{
// POR QUE: o Ecoponto é o pier da margem onde o lixo é separado (Parte 1
// §6). Encostar o barco nele, ou clicar nele, abre a separação.
// ESTRATÉGIA: um collider trigger que entra um pouco na água. Só o casco do
// barco conta (o puçá também é trigger e entraria junto, abrindo a tela
// duas vezes). Clicar com o barco longe manda o barco ao "dock", um ponto
// na água em frente ao pier.
public class Ecopoint : MonoBehaviour, IClickable
{
[SerializeField] private Transform _dock;
private bool _boatInside;
private void OnEnable() => CargoFullHint.Targets.Add(_dock);
private void OnDisable() => CargoFullHint.Targets.Remove(_dock);
private void OnTriggerEnter2D(Collider2D other)
{
var boat = HullOf(other);
if (boat == null) return;
_boatInside = true;
TryOpen(boat);
}
private void OnTriggerExit2D(Collider2D other)
{
if (HullOf(other) != null) _boatInside = false;
}
public void OnClicked(BoatController boat)
{
if (_boatInside) TryOpen(boat);
else boat.SetTarget(_dock.position);
}
// Devolve o barco se o collider for o casco (não o puçá, que é trigger).
private static BoatController HullOf(Collider2D other)
{
if (other.isTrigger) return null;
return other.GetComponentInParent<BoatController>();
}
private void TryOpen(BoatController boat)
{
if (SortingPanel.Instance.IsOpen) return;
var cargo = boat.GetComponent<BoatCargo>();
if (cargo.Items.Count == 0)
{
Toast.Show("O porão está vazio. Recolha lixo no rio!");
return;
}
boat.Stop();
SortingPanel.Instance.Open(cargo.TakeAll());
}
}
}
3.5 Montar a cena
- Ecoponto: um objeto
Ecopointna margem, layerInteract, com o sprite do pier e das 6 lixeiras, um Box Collider 2D Is Trigger que avança uns 1,5 m sobre a água, eEcopoint. Um filho vazioDockna água, em frente ao pier. - Painel de separação (no Canvas
HUD):Sorting, com oSortingPanelnele e um filhoRoot(o que liga e desliga) contendo:- Um fundo escuro de tela inteira (Image com Raycast Target ligado, para bloquear cliques no jogo).
Tray: um painel com Grid Layout Group (células de 140 × 160).- 6 lixeiras (Images grandes nas cores da Parte 1 §5) com
RecycleBin, cada uma com seu rótulo (ex.: "lixeira azul, do papel") e tipos aceitos. FactText(balão) eDoneButton("Voltar ao rio").DragLayer: um RectTransform vazio de tela inteira, último filho doRoot, onde a carta fica durante o arraste.
- Prefab da carta (
Prefabs/UI/TrashCard): Image de fundo, Image do ícone, texto do nome, CanvasGroup eTrashCard. - Cartão (no Canvas
HUD, depois doSortingna Hierarchy, para ficar por cima):InfoCardcomInfoCardPanele oRootcom fundo escuro, Image, título, texto e botão.
✅ Checkpoint da Fase 3
- Com lixo no porão, encostar no Ecoponto para o barco, pausa o jogo (o lixo do rio para de balançar) e mostra as cartas na bandeja.
- Encostar com o porão vazio mostra "O porão está vazio".
- Arrastar para a lixeira certa: a carta some, a lixeira pula e o balão mostra a frase do item.
- Arrastar para a errada: a carta volta, abre o cartão "Ops!" dizendo a lixeira certa, e ela pisca.
- Separar tudo mostra "Voltar ao rio"; ao clicar, o jogo volta a andar e o HUD do porão está vazio.
- Com o porão cheio, a seta aponta para o Ecoponto.
Problemas comuns
- A carta não solta na lixeira (sempre volta): o
blocksRaycastsda carta não foi desligado, ou a Image da lixeira está com Raycast Target desligado. - A tela abre duas vezes: a checagem
other.isTriggerfoi removida, e o puçá entrou junto com o casco. - A carta fica atrás das lixeiras durante o arraste: o
DragLayernão é o último filho doRoot.
Próxima fase: Rio Vivo, Fauna e Álbum, onde os pontos viram a barra, a água clareia e os bichos voltam.
Fase 4 — Rio Vivo, Fauna e Álbum
Objetivo desta fase: recolher e separar enchem a barra do Rio Vivo; a água clareia; ao passar de cada limite, uma espécie aparece, abre o cartão dela na primeira vez e entra no Álbum salvo no navegador.
4.1 O Rio Vivo: RiverHealth
// Caminho: Assets/_Project/Scripts/River/RiverHealth.cs
using System;
using System.Collections.Generic;
using CapibaribeVivo.Data;
using UnityEngine;
namespace CapibaribeVivo.River
{
// POR QUE: o Rio Vivo é a barra principal (Parte 1 §7): sobe com pontos,
// nunca desce, e fica presa num teto enquanto houver fontes de poluição
// abertas.
// ESTRATÉGIA: guarda os pontos brutos e calcula Value = mínimo entre
// (pontos / meta) e o teto. Os pontos acima do teto não se perdem: quando
// uma fonte fecha, o teto sobe e a barra pula. As fontes se registram aqui
// pela interface IPollutionSource (definida abaixo), para o Rio Vivo não
// depender do código das fontes, que só vem na Fase 5.
public class RiverHealth : MonoBehaviour
{
public static RiverHealth Instance { get; private set; }
public event Action<float> Changed; // novo valor, de 0 a 1
public event Action Completed; // chegou a 100%, uma vez só
[SerializeField] private int _goalPoints = 100; // Parte 1 §7 (proposta)
private readonly List<IPollutionSource> _sources = new();
private int _points;
private bool _completed;
public float Value { get; private set; }
public float Ceiling { get; private set; } = 1f;
private void Awake() => Instance = this;
// Uma fonte nova no trecho (ela chama isto no Start dela).
public void Register(IPollutionSource source)
{
_sources.Add(source);
Recalculate();
}
public void AddPoints(int points)
{
_points += points;
Recalculate();
}
// Refaz a conta (as fontes chamam quando fecham).
public void Recalculate()
{
int open = 0;
foreach (var s in _sources) if (s.IsOpen) open++;
float openFraction = _sources.Count == 0 ? 0f : open / (float)_sources.Count;
Ceiling = 1f - GameConfig.Instance.openSourcesCeilingPenalty * openFraction;
float raw = Mathf.Clamp01(_points / (float)_goalPoints);
Value = Mathf.Max(Value, Mathf.Min(raw, Ceiling)); // Max: nunca desce
Changed?.Invoke(Value);
if (!_completed && Value >= 1f)
{
_completed = true;
Completed?.Invoke();
}
}
}
// POR QUE: o contrato mínimo que o Rio Vivo precisa de uma fonte.
public interface IPollutionSource
{
bool IsOpen { get; }
}
}
Agora descomente as linhas do RiverHealth em BoatNet (Fase 2) e SortingPanel (Fase 3).
4.2 Mostrar o Rio Vivo: RiverHealthBar e WaterTint
// Caminho: Assets/_Project/Scripts/UI/RiverHealthBar.cs
using CapibaribeVivo.River;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.UI
{
// POR QUE: a barra do topo da tela (Parte 1 §15), com o cadeado no teto das
// fontes, para a criança ver que "não sobe mais" enquanto a fonte estiver aberta.
// ESTRATÉGIA: uma Image do tipo "Filled" (a Unity corta a imagem conforme o
// fillAmount, de 0 a 1). O enchimento anima até o valor novo. O cadeado é
// posicionado mexendo nas âncoras: âncora X = teto, então ele fica na
// fração certa da barra, qualquer que seja a largura dela.
public class RiverHealthBar : MonoBehaviour
{
[SerializeField] private Image _fill;
[SerializeField] private RectTransform _lock; // ícone de cadeado
[SerializeField] private float _speed = 0.5f; // fração da barra por segundo
private float _target;
private void Start()
{
RiverHealth.Instance.Changed += OnChanged;
OnChanged(RiverHealth.Instance.Value);
_fill.fillAmount = _target;
}
private void OnDestroy()
{
if (RiverHealth.Instance != null) RiverHealth.Instance.Changed -= OnChanged;
}
private void OnChanged(float value)
{
_target = value;
float ceiling = RiverHealth.Instance.Ceiling;
_lock.gameObject.SetActive(ceiling < 1f);
_lock.anchorMin = new Vector2(ceiling, _lock.anchorMin.y);
_lock.anchorMax = new Vector2(ceiling, _lock.anchorMax.y);
}
private void Update()
{
_fill.fillAmount = Mathf.MoveTowards(_fill.fillAmount, _target, _speed * Time.unscaledDeltaTime);
}
}
}
// Caminho: Assets/_Project/Scripts/River/WaterTint.cs
using UnityEngine;
namespace CapibaribeVivo.River
{
// POR QUE: a água muda de cor com o Rio Vivo (Parte 1 §7). É a recompensa
// mais visível do pilar "cada lixo traz o rio de volta".
// ESTRATÉGIA: um Gradient (faixa de cores editável no Inspector) indo do
// marrom ao verde-azulado. A cor muda devagar, para a criança perceber.
// Manchas de óleo e espuma são sprites que somem por limite.
public class WaterTint : MonoBehaviour
{
[SerializeField] private SpriteRenderer _water;
[SerializeField] private Gradient _gradient;
[SerializeField] private float _speed = 0.3f;
[SerializeField] private SpriteRenderer[] _stains; // manchas e espuma
[SerializeField, Range(0f, 1f)] private float _stainsGoneAt = 0.6f;
private float _shown;
private void Update()
{
if (RiverHealth.Instance == null) return;
_shown = Mathf.MoveTowards(_shown, RiverHealth.Instance.Value, _speed * Time.deltaTime);
_water.color = _gradient.Evaluate(_shown);
// As manchas vão ficando transparentes até sumir no limite.
float stainAlpha = 1f - Mathf.Clamp01(_shown / _stainsGoneAt);
foreach (var stain in _stains)
{
Color c = stain.color;
c.a = stainAlpha;
stain.color = c;
}
}
}
}
4.3 Guardar o que a criança descobriu: SaveSystem e AlbumService
O Álbum precisa ser salvo desde o primeiro bicho, então o save entra aqui.
// Caminho: Assets/_Project/Scripts/Data/AlbumEntryDef.cs
using UnityEngine;
namespace CapibaribeVivo.Data
{
// As três abas do Álbum (Parte 1 §12).
public enum AlbumSection { Fauna, Places, Tips }
// POR QUE: bichos, postais e dicas são todos "um cartão do Álbum": imagem,
// título, texto e narração.
// ESTRATÉGIA: uma classe base. FaunaDef herda dela (é um cartão com dados a
// mais); postais e dicas usam esta classe direto. Todos ficam em
// Resources/Album, para a tela do Álbum achar todos sem lista manual (Fase 6).
[CreateAssetMenu(menuName = "Capibaribe Vivo/Album Entry", fileName = "Album_")]
public class AlbumEntryDef : ScriptableObject
{
public string id = "dica-canal"; // não mude depois de publicado: o save usa o id
public AlbumSection section;
public int order; // posição dentro da aba
public string title = "Lixo no chão vai parar no rio";
public Sprite image;
[TextArea(3, 8)] public string text;
public AudioClip narration;
}
}
// Caminho: Assets/_Project/Scripts/Core/SaveSystem.cs
using System;
using System.Collections.Generic;
using CapibaribeVivo.Data;
using UnityEngine;
namespace CapibaribeVivo.Core
{
// O que vai para o disco (Parte 1 §18). [Serializable] deixa o JsonUtility
// transformar isto em texto JSON.
[Serializable]
public class SaveData
{
public int version = 1;
public string characterId = "";
public List<string> completedStretches = new();
public List<string> album = new();
}
// POR QUE: o save é só local (Parte 1 §18): personagem, trechos limpos e
// Álbum. No build Web, o PlayerPrefs da Unity guarda no próprio navegador.
// ESTRATÉGIA: o SaveData inteiro vira um texto JSON numa chave do
// PlayerPrefs. É carregado na primeira vez que alguém pede. Um save
// corrompido não trava o jogo: vira um save novo.
public static class SaveSystem
{
private const string Key = "capibaribevivo-save-v1";
private static SaveData _data;
public static SaveData Data
{
get
{
if (_data == null) Load();
return _data;
}
}
public static void Load()
{
string json = PlayerPrefs.GetString(Key, "");
try
{
_data = string.IsNullOrEmpty(json) ? new SaveData() : JsonUtility.FromJson<SaveData>(json);
}
catch (Exception e)
{
Debug.LogWarning($"Save inválido, começando do zero: {e.Message}");
_data = null;
}
_data ??= new SaveData();
}
public static void Save()
{
PlayerPrefs.SetString(Key, JsonUtility.ToJson(Data));
PlayerPrefs.Save(); // no navegador, é isto que grava de fato
}
// Apaga tudo (botão escondido para o professor, ou para testar).
public static void ResetAll()
{
_data = new SaveData();
Save();
}
// Sem "domain reload" no Play Mode, campos static sobrevivem entre Plays;
// isto garante que cada Play leia o save de novo.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStatics() => _data = null;
}
// POR QUE: desbloquear um cartão do Álbum acontece em três lugares
// (fauna, fonte, postal) e precisa sempre salvar.
// ESTRATÉGIA: uma função que adiciona o id ao save, salva e diz se era novo
// (para o jogo decidir entre "Novo no Álbum!" e "voltou").
public static class AlbumService
{
public static event Action<AlbumEntryDef> Unlocked;
public static bool IsUnlocked(AlbumEntryDef entry) =>
entry != null && SaveSystem.Data.album.Contains(entry.id);
public static bool Unlock(AlbumEntryDef entry)
{
if (entry == null || IsUnlocked(entry)) return false;
SaveSystem.Data.album.Add(entry.id);
SaveSystem.Save();
Unlocked?.Invoke(entry);
return true;
}
}
}
4.4 Os bichos: FaunaDef, FaunaSpawnPoint, FaunaAnimal e FaunaSpawner
// Caminho: Assets/_Project/Scripts/Data/FaunaDef.cs
using UnityEngine;
namespace CapibaribeVivo.Data
{
// Onde o bicho vive: decide onde ele aparece e como se mexe.
public enum FaunaHabitat { Water, Bank, Air }
// POR QUE: cada espécie (Parte 1 §8) é um cartão do Álbum com dados a mais:
// o limite de Rio Vivo em que volta, o habitat e o prefab que anda na cena.
[CreateAssetMenu(menuName = "Capibaribe Vivo/Fauna", fileName = "Fauna_")]
public class FaunaDef : AlbumEntryDef
{
[Range(0f, 1f)] public float healthThreshold = 0.3f;
public FaunaHabitat habitat;
public Fauna.FaunaAnimal prefab;
public int count = 3; // quantos aparecem no trecho
}
}
// Caminho: Assets/_Project/Scripts/Fauna/FaunaSpawnPoint.cs
using CapibaribeVivo.Data;
using UnityEngine;
namespace CapibaribeVivo.Fauna
{
// POR QUE: os bichos não podem nascer em qualquer lugar: peixe na água,
// caranguejo na lama da margem, garça no céu ou pousada.
// ESTRATÉGIA: pontos vazios espalhados à mão no trecho, cada um com um
// habitat. O spawner sorteia entre os pontos do habitat certo.
public class FaunaSpawnPoint : MonoBehaviour
{
[SerializeField] private FaunaHabitat _habitat;
[SerializeField] private float _wanderRadius = 1.5f;
public FaunaHabitat Habitat => _habitat;
public float WanderRadius => _wanderRadius;
private void OnDrawGizmos()
{
Gizmos.color = _habitat switch
{
FaunaHabitat.Water => Color.blue,
FaunaHabitat.Bank => new Color(0.5f, 0.3f, 0.1f),
_ => Color.white,
};
Gizmos.DrawWireSphere(transform.position, _wanderRadius);
}
}
}
// Caminho: Assets/_Project/Scripts/Fauna/FaunaAnimal.cs
using System.Collections;
using CapibaribeVivo.Boat;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using CapibaribeVivo.UI;
using UnityEngine;
namespace CapibaribeVivo.Fauna
{
// POR QUE: um bicho que voltou precisa parecer vivo (passear) e ensinar
// (clicar mostra o cartão dele, Parte 1 §8).
// ESTRATÉGIA: "andar à toa" simples: sorteia um ponto dentro de um círculo
// em volta de casa, vai até ele, espera um pouco e sorteia outro. O sprite
// vira para o lado em que anda. Nasce crescendo de 0 até o tamanho normal.
public class FaunaAnimal : MonoBehaviour, IClickable
{
[SerializeField] private SpriteRenderer _sprite;
[SerializeField] private float _speed = 0.8f;
[SerializeField] private Vector2 _pause = new Vector2(0.5f, 2f); // espera entre passeios
private FaunaDef _def;
private Vector2 _home;
private float _radius;
private Vector2 _goal;
private float _waitUntil;
public void Init(FaunaDef def, Vector2 home, float radius)
{
_def = def;
_home = home;
_radius = radius;
_goal = home;
StartCoroutine(Appear());
}
private IEnumerator Appear()
{
Vector3 full = transform.localScale;
for (float t = 0f; t < 0.5f; t += Time.deltaTime)
{
transform.localScale = full * Mathf.SmoothStep(0f, 1f, t / 0.5f);
yield return null;
}
transform.localScale = full;
}
private void Update()
{
if (Time.time < _waitUntil) return;
Vector2 pos = transform.position;
if ((pos - _goal).sqrMagnitude < 0.01f)
{
_goal = _home + Random.insideUnitCircle * _radius;
_waitUntil = Time.time + Random.Range(_pause.x, _pause.y);
return;
}
Vector2 next = Vector2.MoveTowards(pos, _goal, _speed * Time.deltaTime);
if (Mathf.Abs(next.x - pos.x) > 0.0001f) _sprite.flipX = next.x < pos.x;
transform.position = next;
}
public void OnClicked(BoatController boat)
{
if (_def != null)
InfoCardPanel.Instance.Show(_def.title, _def.text, _def.image, _def.narration);
}
}
}
// Caminho: Assets/_Project/Scripts/Fauna/FaunaSpawner.cs
using System.Collections.Generic;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using CapibaribeVivo.River;
using CapibaribeVivo.UI;
using UnityEngine;
namespace CapibaribeVivo.Fauna
{
// POR QUE: os bichos voltam quando o Rio Vivo passa do limite de cada um
// (Parte 1 §8); na primeira vez no jogo, abre o cartão e entra no Álbum.
// ESTRATÉGIA: escuta o evento Changed do Rio Vivo. Para cada espécie do
// trecho que ainda não apareceu e cujo limite foi passado, cria os bichos
// nos pontos do habitat dela. AlbumService.Unlock diz se é novidade.
public class FaunaSpawner : MonoBehaviour
{
[SerializeField] private FaunaDef[] _fauna; // as espécies deste trecho
private readonly HashSet<FaunaDef> _spawned = new();
private FaunaSpawnPoint[] _points;
private void Start()
{
_points = FindObjectsByType<FaunaSpawnPoint>(FindObjectsSortMode.None);
RiverHealth.Instance.Changed += OnHealthChanged;
}
private void OnDestroy()
{
if (RiverHealth.Instance != null) RiverHealth.Instance.Changed -= OnHealthChanged;
}
private void OnHealthChanged(float value)
{
foreach (var def in _fauna)
{
if (_spawned.Contains(def) || value < def.healthThreshold) continue;
_spawned.Add(def);
Spawn(def);
if (AlbumService.Unlock(def))
InfoCardPanel.Instance.Show($"Novo no Álbum: {def.title}!", def.text, def.image, def.narration);
else
Toast.Show($"{def.title} voltou!");
}
}
private void Spawn(FaunaDef def)
{
var candidates = new List<FaunaSpawnPoint>();
foreach (var p in _points)
if (p.Habitat == def.habitat) candidates.Add(p);
if (candidates.Count == 0)
{
Debug.LogWarning($"Nenhum FaunaSpawnPoint de {def.habitat} para {def.title}.", this);
return;
}
for (int i = 0; i < def.count; i++)
{
FaunaSpawnPoint point = candidates[Random.Range(0, candidates.Count)];
Vector2 pos = (Vector2)point.transform.position + Random.insideUnitCircle * point.WanderRadius;
FaunaAnimal animal = Instantiate(def.prefab, pos, Quaternion.identity, transform);
animal.Init(def, point.transform.position, point.WanderRadius);
}
}
}
}
4.5 Montar a cena
- Rio Vivo: no objeto
River, adicioneRiverHealth(meta 100) eWaterTint(arraste oWater; no Gradient, marrom-acinzentado#6B6650em 0 e verde-azulado#3FA7A0em 1). - Barra: no
HUD, uma barra no topo com uma Image de fundo e uma ImageFill(Image Type = Filled, Fill Method = Horizontal) e um ícone de cadeado como filho da barra.RiverHealthBarcom os dois. - Fauna: crie a pasta
Resources/Album/e, nela, osFaunaDefdo trecho (ex.: garça-branca em 0.3, capivara em 0.6), cada um com um prefab: sprite, Circle Collider 2D Is Trigger na layerInteract(para o clique) eFaunaAnimal. - Espalhe
FaunaSpawnPoints: alguns na água, alguns na margem, alguns no ar sobre o rio. - Um objeto
FaunacomFaunaSpawnere a lista de espécies do trecho.
✅ Checkpoint da Fase 4
- Recolher e separar enchem a barra do topo; ela nunca desce.
- A água muda de marrom para verde-azulado aos poucos, e as manchas somem.
- Ao passar de 30%, as garças aparecem crescendo e abre o cartão "Novo no Álbum: Garça-branca!", com o jogo pausado.
- Sair do Play e entrar de novo: ao passar de 30%, aparece só o aviso "Garça-branca voltou!" (o Álbum foi salvo).
- Clicar numa garça abre o cartão dela.
Problemas comuns
- Os bichos não aparecem: o
FaunaSpawnerligou o evento noStartdepois da barra já ter mudado? Não é o caso aqui (a barra começa em 0), mas, se um dia o trecho começar com Rio Vivo acima de 0, chameOnHealthChanged(RiverHealth.Instance.Value)no fim doStart. - Para testar o Álbum de novo do zero: chame
SaveSystem.ResetAll()(ex.: um botão de debug) ou, no Editor, Edit → Clear All PlayerPrefs. - A barra fica parada em 60%: é o teto das fontes, funcionando. Só que, sem fontes na cena, o teto é 100%: se parar antes, confira a meta de pontos.
Próxima fase: Fontes de Poluição, os canais e esgotos que seguram o teto do Rio Vivo.
Fase 5 — Fontes de Poluição
Objetivo desta fase: canais e esgotos mandam lixo novo e prendem o Rio Vivo no teto; levar o barco até eles abre uma conversa com um(a) morador(a) e, no fim, a fonte fecha e a barra pula.
5.1 A conversa: DialoguePanel
// Caminho: Assets/_Project/Scripts/UI/DialoguePanel.cs
using System;
using CapibaribeVivo.Core;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.UI
{
// POR QUE: cada fonte tem 2 a 4 falas curtas de um(a) morador(a), narradas,
// e a última tem o botão da ação (Parte 1 §9).
// ESTRATÉGIA: um painel com retrato, nome e fala. O botão diz "Próximo"
// até a última fala, onde vira o texto da ação ("Instalar ecobarreira").
// Clicar nele na última fala fecha o painel e chama a ação.
public class DialoguePanel : MonoBehaviour
{
public static DialoguePanel Instance { get; private set; }
[SerializeField] private GameObject _root;
[SerializeField] private Image _portrait;
[SerializeField] private TMP_Text _speaker;
[SerializeField] private TMP_Text _line;
[SerializeField] private Button _next;
[SerializeField] private TMP_Text _nextLabel;
private string[] _lines;
private AudioClip[] _audio;
private string _actionLabel;
private Action _onAction;
private int _index;
private void Awake()
{
Instance = this;
_root.SetActive(false);
_next.onClick.AddListener(Advance);
}
public void Show(string speaker, Sprite portrait, string[] lines, AudioClip[] audio,
string actionLabel, Action onAction)
{
_speaker.text = speaker;
_portrait.sprite = portrait;
_lines = lines;
_audio = audio;
_actionLabel = actionLabel;
_onAction = onAction;
_index = 0;
_root.SetActive(true);
GamePause.Push();
ShowLine();
}
private void ShowLine()
{
_line.text = _lines[_index];
bool last = _index == _lines.Length - 1;
_nextLabel.text = last ? _actionLabel : "Próximo";
AudioClip clip = _audio != null && _index < _audio.Length ? _audio[_index] : null;
Narrator.Instance?.Play(clip);
}
private void Advance()
{
if (_index < _lines.Length - 1)
{
_index++;
ShowLine();
return;
}
_root.SetActive(false);
Narrator.Instance?.Stop();
GamePause.Pop();
Action action = _onAction;
_onAction = null;
action?.Invoke();
}
}
}
5.2 A fonte: PollutionSource
// Caminho: Assets/_Project/Scripts/Sources/PollutionSource.cs
using CapibaribeVivo.Boat;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using CapibaribeVivo.River;
using CapibaribeVivo.Trash;
using CapibaribeVivo.UI;
using UnityEngine;
namespace CapibaribeVivo.Sources
{
public enum SourceKind { StormDrain, Sewage } // canal da rua, esgoto (Parte 1 §9)
// POR QUE: as fontes ensinam a causa da poluição (Parte 1 §9): enquanto
// abertas, mandam lixo novo e seguram o teto do Rio Vivo; para fechar, a
// criança vai até lá e ouve um(a) morador(a).
// ESTRATÉGIA: um timer manda um item do "pool" da fonte a cada intervalo,
// se houver pouco lixo dela no rio (conta no FloatingTrash.All). Clicar com
// o barco longe manda o barco ao "dock" e lembra que a conversa está
// pendente; ao chegar perto, a conversa abre sozinha. A fonte de esgoto
// manda baronesas porque elas estão no pool dela (são um TrashItemDef do
// tipo Orgânico): não precisa de código a mais.
public class PollutionSource : MonoBehaviour, IClickable, IPollutionSource
{
[Header("O que manda")]
[SerializeField] private SourceKind _kind;
[SerializeField] private FloatingTrash _trashPrefab;
[SerializeField] private TrashItemDef[] _pool;
[SerializeField] private float _interval = 6f; // Parte 1 §9 (proposta)
[SerializeField] private int _maxFloating = 6;
[SerializeField] private Transform _spawnPoint; // na água, na boca do canal
[Header("Conversa")]
[SerializeField] private Transform _dock; // na água, em frente à fonte
[SerializeField] private float _talkRadius = 1.5f;
[SerializeField] private string _speaker = "Dona Cida";
[SerializeField] private Sprite _portrait;
[SerializeField, TextArea(2, 4)] private string[] _lines;
[SerializeField] private AudioClip[] _linesAudio;
[SerializeField] private string _actionLabel = "Instalar ecobarreira";
[SerializeField] private AlbumEntryDef _tip; // a dica que entra no Álbum
[Header("Visual")]
[SerializeField] private GameObject _openVisual; // água suja saindo
[SerializeField] private GameObject _closedVisual; // ecobarreira / cano consertado
private float _timer;
private BoatController _pendingBoat;
public bool IsOpen { get; private set; } = true;
public SourceKind Kind => _kind;
private void Start()
{
RiverHealth.Instance.Register(this);
ApplyVisual();
}
private void Update()
{
if (!IsOpen) return;
_timer += Time.deltaTime;
if (_timer >= _interval)
{
_timer = 0f;
if (CountMine() < _maxFloating) SpawnOne();
}
// O barco foi mandado para cá e chegou: abre a conversa.
if (_pendingBoat != null && IsNear(_pendingBoat))
{
BoatController boat = _pendingBoat;
_pendingBoat = null;
StartTalk(boat);
}
}
public void OnClicked(BoatController boat)
{
if (!IsOpen) return;
if (IsNear(boat)) StartTalk(boat);
else
{
boat.SetTarget(_dock.position);
_pendingBoat = boat;
}
}
private bool IsNear(BoatController boat) =>
Vector2.Distance(boat.transform.position, _dock.position) <= _talkRadius;
private void StartTalk(BoatController boat)
{
boat.Stop();
DialoguePanel.Instance.Show(_speaker, _portrait, _lines, _linesAudio, _actionLabel, Close);
}
// A ação da última fala: a fonte para de sujar o rio.
private void Close()
{
IsOpen = false;
ApplyVisual();
RiverHealth.Instance.AddPoints(GameConfig.Instance.pointsPerSourceClosed); // também refaz o teto
if (AlbumService.Unlock(_tip)) Toast.Show($"Nova dica no Álbum: {_tip.title}");
}
private int CountMine()
{
int n = 0;
foreach (var t in FloatingTrash.All) if (t.Source == this) n++;
return n;
}
private void SpawnOne()
{
Vector2 pos = (Vector2)_spawnPoint.position + Random.insideUnitCircle * 0.3f;
FloatingTrash trash = Instantiate(_trashPrefab, pos, Quaternion.identity);
trash.Init(_pool[Random.Range(0, _pool.Length)], this);
}
private void ApplyVisual()
{
if (_openVisual != null) _openVisual.SetActive(IsOpen);
if (_closedVisual != null) _closedVisual.SetActive(!IsOpen);
}
private void OnDrawGizmosSelected()
{
if (_dock == null) return;
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(_dock.position, _talkRadius);
}
}
}
5.3 Montar a cena
- Baronesa: crie o item
Trash_Baronesa(tipo Organic), com uma frase como "A baronesa cresce demais quando a água tem muito esgoto." (a validar, Parte 1 §2). - Fonte de canal: na margem, um objeto
Source_Canal, layerInteract, com o sprite da boca do canal, um Box Collider 2D Is Trigger (área de clique) ePollutionSource:- Kind
StormDrain, pool com garrafa PET, sacola e latinha, e os filhosSpawnPoint(na água, na boca do canal) eDock. - 2 a 4 falas (exemplo, a revisar com educador(a)): "Oi! Esse canal traz a água da chuva da rua para o rio." / "Quando alguém joga lixo no chão, a chuva leva ele pelo bueiro até aqui." / "Uma ecobarreira segura o lixo antes de ele chegar no rio. Me ajuda a instalar?". Ação: "Instalar ecobarreira".
- Um
AlbumEntryDefde dica (aba Tips) emResources/Album/: "Lixo no chão vai parar no rio". OpenVisual(água escura saindo) eClosedVisual(uma boia de ecobarreira atravessando a boca).
- Kind
- Fonte de esgoto: igual, com Kind
Sewage, pool com baronesas e casca de banana, e falas sobre o esgoto. Ação: "Chamar a equipe de saneamento". - Conversa: no
HUD,DialoguecomDialoguePanel(retrato à esquerda, nome, fala, botão), entre oSortinge oInfoCardna Hierarchy.
✅ Checkpoint da Fase 5
- Com as duas fontes abertas, a barra para em 60% com o cadeado, mesmo recolhendo mais.
- A cada 6 s aparece um lixo novo na boca de cada fonte, até 6 de cada uma no rio.
- Clicar na fonte com o barco longe leva o barco até ela, e a conversa abre sozinha ao chegar.
- As falas avançam com "Próximo" e a narração de cada uma; a última mostra a ação.
- Ao fazer a ação: o visual da fonte muda, ela para de mandar lixo, a barra pula (o cadeado vai para 80%) e aparece "Nova dica no Álbum".
- Com as duas fechadas, o cadeado some e a barra chega a 100%.
Problemas comuns
- O trecho nunca chega a 100%: não há lixo suficiente. Conta rápida: (lixo inicial × 3) + (fontes × 15) precisa passar da meta, com folga, porque a criança erra a separação. Com 30 lixos e 2 fontes: 90 + 30 = 120, mais o lixo que as fontes mandarem.
- Clicar na fonte move o barco para dentro da terra: o
Dockestá em terra. Ele tem que ficar na água. - A conversa abre duas vezes: clicar de novo na fonte enquanto o barco ainda vai até ela é ok (só troca o pendente), mas abrir a conversa e clicar fora não deve acontecer: o fundo do
DialoguePanelprecisa bloquear cliques (Image com Raycast Target).
Próxima fase: Trechos, Mapa e Postal, transformando este trecho num jogo de 6 trechos.
Fase 6 — Trechos, Mapa e Postal
Objetivo desta fase: limpar um trecho mostra o postal e volta ao mapa, onde o próximo trecho abriu; o mapa também abre o Álbum. Os outros 5 trechos são montados a partir do primeiro.
6.1 O dado do trecho: StretchDef
Substitua a classe vazia da Fase 2 por esta:
// Caminho: Assets/_Project/Scripts/Data/StretchDef.cs
using UnityEngine;
namespace CapibaribeVivo.Data
{
// POR QUE: cada trecho do rio (Parte 1 §10) tem nome, cena, posição no
// mapa e o postal que entra no Álbum.
// ESTRATÉGIA: um asset por trecho, listados em ordem no GameConfig. A
// posição no mapa é uma fração (0 a 1) da imagem do mapa, para o botão
// ficar no lugar certo em qualquer tamanho de tela.
[CreateAssetMenu(menuName = "Capibaribe Vivo/Stretch", fileName = "Stretch_")]
public class StretchDef : ScriptableObject
{
public string id = "varzea"; // não mude depois de publicado: o save usa o id
public string displayName = "Várzea";
public string sceneName = "Stretch_01_Varzea";
public Vector2 mapPosition = new Vector2(0.1f, 0.5f);
public AlbumEntryDef postcard; // aba Places
}
}
6.2 Fim do trecho: StretchManager
// Caminho: Assets/_Project/Scripts/Progress/StretchManager.cs
using System.Collections;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using CapibaribeVivo.River;
using CapibaribeVivo.UI;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace CapibaribeVivo.Progress
{
// POR QUE: com o Rio Vivo em 100%, o trecho acaba (Parte 1 §3): salva que
// foi limpo, mostra o postal e volta ao mapa.
// ESTRATÉGIA: espera uns segundos depois do 100% (a criança merece ver o
// rio limpo e os bichos passeando antes do postal) e usa o InfoCardPanel
// com o botão "Voltar ao mapa". Também guarda os botões do HUD.
public class StretchManager : MonoBehaviour
{
[SerializeField] private StretchDef _stretch;
[SerializeField] private float _postcardDelay = 3f;
private void Start() => RiverHealth.Instance.Completed += OnCompleted;
private void OnDestroy()
{
if (RiverHealth.Instance != null) RiverHealth.Instance.Completed -= OnCompleted;
}
private void OnCompleted() => StartCoroutine(Finish());
private IEnumerator Finish()
{
Toast.Show("O rio está limpo!");
yield return new WaitForSecondsRealtime(_postcardDelay);
var save = SaveSystem.Data;
if (!save.completedStretches.Contains(_stretch.id))
{
save.completedStretches.Add(_stretch.id);
SaveSystem.Save();
}
AlbumService.Unlock(_stretch.postcard);
AlbumEntryDef card = _stretch.postcard;
InfoCardPanel.Instance.Show(card.title, card.text, card.image, card.narration,
"Voltar ao mapa", BackToMap);
}
// Ligado ao botão "mapa" do HUD (Parte 1 §15) e ao fim do postal.
public void BackToMap() => SceneManager.LoadScene(GameConfig.Instance.mapScene);
// Ligado ao botão "ouvir de novo" do HUD.
public void RepeatNarration() => Narrator.Instance?.RepeatLast();
}
}
6.3 O mapa: MapScreen e StretchButton
// Caminho: Assets/_Project/Scripts/Progress/StretchButton.cs
using System;
using CapibaribeVivo.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.Progress
{
// POR QUE: um botão no mapa por trecho, com três estados: trancado, aberto
// e limpo (Parte 1 §10).
// ESTRATÉGIA: o MapScreen decide o estado; o botão só mostra (cadeado,
// visto) e avisa quando é clicado.
public class StretchButton : MonoBehaviour
{
[SerializeField] private Button _button;
[SerializeField] private TMP_Text _label;
[SerializeField] private GameObject _lockIcon;
[SerializeField] private GameObject _doneIcon;
public void Setup(StretchDef def, bool unlocked, bool done, Action<StretchDef> onClick)
{
_label.text = def.displayName;
_button.interactable = unlocked;
_lockIcon.SetActive(!unlocked);
_doneIcon.SetActive(done);
_button.onClick.RemoveAllListeners();
_button.onClick.AddListener(() => onClick(def));
}
}
}
// Caminho: Assets/_Project/Scripts/Progress/MapScreen.cs
using System.Collections.Generic;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace CapibaribeVivo.Progress
{
// POR QUE: o mapa é o desenho do rio atravessando o Recife, com um botão
// por trecho; limpar um trecho abre o próximo (Parte 1 §10).
// ESTRATÉGIA: cria um StretchButton por trecho do GameConfig, posicionado
// pelas âncoras (mapPosition) sobre a imagem do mapa. O trecho i está aberto
// se for o primeiro ou se o anterior estiver limpo.
public class MapScreen : MonoBehaviour
{
[SerializeField] private RectTransform _mapImage;
[SerializeField] private StretchButton _buttonPrefab;
[SerializeField] private string _menuScene = "Menu";
private void Start()
{
List<StretchDef> stretches = GameConfig.Instance.stretches;
var done = SaveSystem.Data.completedStretches;
for (int i = 0; i < stretches.Count; i++)
{
StretchDef def = stretches[i];
bool isDone = done.Contains(def.id);
bool unlocked = i == 0 || done.Contains(stretches[i - 1].id);
StretchButton button = Instantiate(_buttonPrefab, _mapImage);
var rect = (RectTransform)button.transform;
rect.anchorMin = rect.anchorMax = def.mapPosition;
rect.anchoredPosition = Vector2.zero;
button.Setup(def, unlocked, isDone, Play);
}
}
private void Play(StretchDef def) => SceneManager.LoadScene(def.sceneName);
// Ligado ao botão "Trocar personagem".
public void BackToMenu() => SceneManager.LoadScene(_menuScene);
}
}
6.4 O Álbum: AlbumScreen e AlbumSlot
// Caminho: Assets/_Project/Scripts/Progress/AlbumSlot.cs
using System;
using CapibaribeVivo.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace CapibaribeVivo.Progress
{
// POR QUE: uma figurinha do Álbum: a imagem e o nome, ou a silhueta com "?"
// se ainda não foi descoberta (Parte 1 §12).
// ESTRATÉGIA: a silhueta é a mesma imagem pintada de preto (Image.color),
// sem precisar de arte a mais.
public class AlbumSlot : MonoBehaviour
{
[SerializeField] private Image _image;
[SerializeField] private TMP_Text _label;
[SerializeField] private Button _button;
public void Setup(AlbumEntryDef entry, bool unlocked, Action<AlbumEntryDef> onClick)
{
_image.sprite = entry.image;
_image.color = unlocked ? Color.white : Color.black;
_label.text = unlocked ? entry.title : "?";
_button.interactable = unlocked;
_button.onClick.RemoveAllListeners();
_button.onClick.AddListener(() => onClick(entry));
}
}
}
// Caminho: Assets/_Project/Scripts/Progress/AlbumScreen.cs
using System.Collections.Generic;
using System.Linq;
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using CapibaribeVivo.UI;
using UnityEngine;
namespace CapibaribeVivo.Progress
{
// POR QUE: o Álbum mostra o que a criança já descobriu, em três abas
// (Parte 1 §12).
// ESTRATÉGIA: Resources.LoadAll acha todos os cartões da pasta
// Resources/Album (bichos, postais, dicas), sem lista manual: criar um
// cartão novo já o põe no Álbum. Trocar de aba apaga as figurinhas e cria
// as da aba nova, em ordem.
public class AlbumScreen : MonoBehaviour
{
[SerializeField] private GameObject _root;
[SerializeField] private Transform _grid; // tem um Grid Layout Group
[SerializeField] private AlbumSlot _slotPrefab;
private AlbumEntryDef[] _all;
private void Awake()
{
_all = Resources.LoadAll<AlbumEntryDef>("Album");
_root.SetActive(false);
}
// Ligados aos botões: abrir, fechar e as três abas (0 Bichos, 1 Lugares, 2 Dicas).
public void Open()
{
_root.SetActive(true);
ShowSection(0);
}
public void Close() => _root.SetActive(false);
public void ShowSection(int section)
{
foreach (Transform child in _grid) Destroy(child.gameObject);
IEnumerable<AlbumEntryDef> entries = _all
.Where(e => (int)e.section == section)
.OrderBy(e => e.order);
foreach (var entry in entries)
{
AlbumSlot slot = Instantiate(_slotPrefab, _grid);
slot.Setup(entry, AlbumService.IsUnlocked(entry), ShowCard);
}
}
private void ShowCard(AlbumEntryDef e) =>
InfoCardPanel.Instance.Show(e.title, e.text, e.image, e.narration);
}
}
6.5 Montar a cena Map
- Um Canvas com a imagem do mapa (o rio do oeste ao leste, Parte 1 §10) e um objeto
MapScreen. - Prefab
StretchButton: botão redondo com nome, cadeado e visto. - Crie os 6
StretchDefemScriptableObjects/Stretches/com os dados da tabela da Parte 1 §10, um postal (AlbumEntryDef, aba Places) para cada, emResources/Album/, e liste os trechos em ordem noGameConfig. - O
AlbumScreen(com oRoot, as 3 abas e o grid) e umInfoCardPaneltambém no Canvas do mapa, para abrir os cartões do Álbum. - Botões: Álbum (
AlbumScreen.Open) e Trocar personagem (MapScreen.BackToMenu).
6.6 Montar os outros trechos a partir do primeiro
Cada trecho é uma cena, mas quase tudo nela é igual: barco, câmera, input, HUD, Rio Vivo, Ecoponto. Só mudam o rio desenhado, os marcos, as fontes, a fauna e o StretchDef.
- Em
Stretch_01_Varzea, coloqueStretchManagerno objetoRiver(com oStretchDefda Várzea) e ligue os botões do HUD: mapa emBackToMape ouvir de novo emRepeatNarration. - Transforme em prefabs o que é igual em todo trecho:
Boat(comNeteArrow),Input,HUD(comSorting,Dialogue,InfoCard,Toast) eEcopoint. - Com a cena da Várzea aberta: File → Save As Scene Template, nome
StretchTemplate. - Para cada trecho novo: File → New Scene → StretchTemplate, salve como
Stretch_02_Apipucos(etc.), adicione à Scene List e mude:- as margens (Sprite Shape) e os marcos da margem;
- a direção do
RiverFlowe o retângulo da câmera e doTrashScatter; - as fontes (tipo, falas e dica, Parte 1 §10) e a meta de pontos do
RiverHealth; - as espécies do
FaunaSpawnere osFaunaSpawnPoints; - o
StretchDefdoStretchManager.
✅ Checkpoint da Fase 6
- Chegar a 100% na Várzea mostra "O rio está limpo!", e, 3 s depois, o postal com "Voltar ao mapa".
- No mapa, a Várzea tem o visto e Apipucos abriu; os outros continuam com cadeado.
- Recarregar a página (ou sair e entrar no Play) mantém o progresso.
- O Álbum mostra os bichos já vistos, o postal da Várzea e as dicas das fontes fechadas; o resto aparece como silhueta com "?".
- Os 6 trechos abrem do mapa e cada um tem o seu postal.
Problemas comuns
- O jogo fica pausado depois de voltar ao mapa: algum painel fez
PushsemPop. OGamePausezera ao trocar de cena, mas, se acontecer dentro do trecho, procure um painel fechado semGamePause.Pop(). - Um trecho não abre: o
sceneNamedoStretchDefestá diferente do nome da cena, ou a cena não está na Scene List. - O Álbum está vazio: os cartões não estão em
Resources/Album/(precisa ser exatamente uma pastaAlbumdentro de uma pastaResources).
Próxima fase: Personagem e Narração, com a escolha de quem rema e a gravação das vozes.
Fase 7 — Personagem e Narração
Objetivo desta fase: a criança escolhe quem rema no menu, o barco aparece com esse personagem em todos os trechos, e todas as frases do jogo têm voz.
7.1 Os personagens: CharacterDef, CharacterSelectScreen e BoatSkin
Substitua a classe vazia da Fase 2 por esta:
// Caminho: Assets/_Project/Scripts/Data/CharacterDef.cs
using UnityEngine;
namespace CapibaribeVivo.Data
{
// POR QUE: a criança escolhe quem rema (Parte 1 §11); a escolha é só visual.
// ESTRATÉGIA: um asset por personagem, com o retrato do menu e o sprite do
// barco visto de cima com o personagem dentro.
[CreateAssetMenu(menuName = "Capibaribe Vivo/Character", fileName = "Character_")]
public class CharacterDef : ScriptableObject
{
public string id = "ana";
public string displayName = "Ana";
public Sprite portrait;
public Sprite boatSprite;
public AudioClip nameNarration; // "Ana!", tocado ao escolher
}
}
// Caminho: Assets/_Project/Scripts/Progress/CharacterSelectScreen.cs
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace CapibaribeVivo.Progress
{
// POR QUE: a primeira tela do jogo: escolher o personagem e entrar no mapa.
// Também serve de "toque para começar": no navegador, o som só pode tocar
// depois do primeiro clique da pessoa, e esta tela garante esse clique.
// ESTRATÉGIA: cria um botão com o retrato de cada personagem do GameConfig.
// Escolher salva o id e abre o mapa.
public class CharacterSelectScreen : MonoBehaviour
{
[SerializeField] private Transform _grid;
[SerializeField] private Button _portraitPrefab; // botão com uma Image
private void Start()
{
foreach (CharacterDef character in GameConfig.Instance.characters)
{
Button button = Instantiate(_portraitPrefab, _grid);
button.image.sprite = character.portrait;
button.onClick.AddListener(() => Pick(character));
}
}
private void Pick(CharacterDef character)
{
Narrator.Instance?.Play(character.nameNarration);
SaveSystem.Data.characterId = character.id;
SaveSystem.Save();
SceneManager.LoadScene(GameConfig.Instance.mapScene);
}
}
}
// Caminho: Assets/_Project/Scripts/Boat/BoatSkin.cs
using CapibaribeVivo.Core;
using CapibaribeVivo.Data;
using UnityEngine;
namespace CapibaribeVivo.Boat
{
// POR QUE: o barco de todos os trechos mostra o personagem escolhido.
// ESTRATÉGIA: no Start, procura no GameConfig o personagem com o id salvo e
// troca o sprite. Sem escolha salva (ex.: abrindo um trecho direto no
// Editor), fica o sprite padrão do prefab.
public class BoatSkin : MonoBehaviour
{
[SerializeField] private SpriteRenderer _renderer;
private void Start()
{
string id = SaveSystem.Data.characterId;
foreach (CharacterDef character in GameConfig.Instance.characters)
{
if (character.id == id)
{
_renderer.sprite = character.boatSprite;
return;
}
}
}
}
}
7.2 Montar o menu
- Na cena
Menu, um Canvas com o título "Capibaribe Vivo", a pergunta "Quem vai remar?" (com narração) e o grid de retratos, comCharacterSelectScreen. - Crie os personagens em
ScriptableObjects/Characters/e liste-os noGameConfig. - No prefab
Boat, adicioneBoatSkinapontando para o Sprite Renderer.
7.3 Gravar a narração
O código já toca narração em todo lugar (TrashItemDef.factNarration, AlbumEntryDef.narration, falas das fontes, nome dos personagens). Esta parte é conteúdo:
- Lista de falas: monte uma planilha com toda frase do jogo (frases dos itens, cartões, falas das fontes, avisos) e o arquivo de áudio de cada uma. Revise os textos com o(a) educador(a) antes de gravar (Parte 1 §2).
- Gravação: um arquivo por frase, em
Assets/_Project/Audio/Narration/, com nomes que batem com a planilha (ex.:trash_pet.wav,fauna_garca.wav). - Importação (selecione todos os arquivos no Project): Force To Mono ligado, Load Type = Compressed In Memory, Compression Format = Vorbis, Quality 50%. Voz não precisa de estéreo, e o tamanho do build Web importa (Fase 8).
- Arraste cada áudio no campo do asset certo.
- Os avisos curtos do
Toastficam sem voz no MVP; se o teste mostrar que as crianças não os leem, adicione umAudioClipaoToast.Show.
✅ Checkpoint da Fase 7
- Abrir o jogo mostra o menu com os retratos; escolher um diz o nome e abre o mapa.
- Em qualquer trecho, o barco mostra o personagem escolhido.
- "Trocar personagem" no mapa volta ao menu, e a escolha nova vale nos trechos.
- Cartões, falas e frases dos itens tocam a voz; uma voz nova corta a anterior; "ouvir de novo" repete a última.
Problemas comuns
- No build Web, a primeira narração não toca: o navegador bloqueia som antes do primeiro clique. A tela de personagem existe para isso: não toque narração sozinha no menu antes do primeiro clique.
- A voz fica baixa perto da música: use um Audio Mixer com dois grupos (Voz e Música) e abaixe a música enquanto o
Narratortoca.
Próxima fase: Build Web e Próximos Passos.
Fase 8 — Build Web e Próximos Passos
Objetivo desta fase: o jogo rodando no navegador, leve o bastante para os computadores da escola, e a lista do que vem depois do MVP.
8.1 Configurar o build Web
- File → Build Profiles → Web → Switch Platform. Confira a Scene List:
Menu,Mape os 6 trechos, comMenuem primeiro. - Player Settings → Resolution and Presentation: tamanho padrão 1280 × 720, Run In Background desligado.
- Player Settings → Publishing Settings:
- Compression Format = Gzip, com Decompression Fallback ligado. Os servidores das escolas (e muitos hospedeiros simples) não enviam os cabeçalhos que o Brotli/Gzip precisam; com o fallback, o jogo abre em qualquer lugar, só um pouco mais devagar na primeira vez.
- Player Settings → Other Settings: Managed Stripping Level = Medium (diminui o build). Se algo quebrar só no build, volte para Low.
- Build Profiles → Build numa pasta
Build/Web/. Para testar, use Build And Run: a Unity sobe um servidor local. Abrir oindex.htmldireto do disco não funciona.
8.2 Testar como na escola
- Teste num computador fraco (ou com a CPU limitada pelas ferramentas do navegador): o trecho precisa rodar liso com 30 lixos, 2 fontes e os bichos.
- Teste com mouse de verdade e com uma criança: o tamanho dos alvos de clique, a velocidade do barco e o porão de 8 vêm daí (Parte 1 §20, números de proposta).
- Teste o save: jogue um trecho, feche a aba e abra de novo; depois teste em janela anônima (o save some, o que é o comportamento esperado e o risco anotado na Parte 1 §18).
- Meça o tamanho do build: a narração é o que mais pesa. Se passar de uns 50 MB, reduza a qualidade do Vorbis.
8.3 Publicar
- Em aberto: onde hospedar (o GitHub Pages do estúdio, um portal de jogos educativos, o site da escola). Qualquer hospedagem de arquivos estáticos funciona, por causa do Decompression Fallback.
✅ Checkpoint da Fase 8
- O build abre no Chrome, Firefox e Edge, no endereço publicado, sem erros no console do navegador.
- O jogo inteiro funciona do menu ao 6º trecho, com som e narração.
- O progresso continua depois de fechar e abrir a aba.
- O trecho roda liso num computador fraco.
Problemas comuns
- Tela com "Unable to parse Build/....framework.js.gz": o servidor não manda o cabeçalho
Content-Encoding. Ligue Decompression Fallback (§8.1) e faça o build de novo. - O progresso some entre as aulas: o navegador da escola limpa os dados ao fechar. Não há conserto no código; é o "código do progresso" em aberto (Parte 1 §18).
8.4 Próximos passos (depois do MVP)
Na ordem sugerida, todos Em aberto na Parte 1:
- Validação dos fatos e textos com educador(a) e biólogo(a), antes de qualquer teste em escola.
- Modo de 3 lixeiras para os menores (só montar lixeiras com listas maiores, Fase 3 §3.3).
- Perfis locais ou código do progresso, para o computador compartilhado da escola.
- Versão para tablet: o controle já usa
Pointer(mouse e toque); falta o layout da UI em telas menores e o build Android/iOS. - Trechos do Agreste e da nascente, com a fonte das lavanderias de jeans.
- Guia do professor e Álbum imprimível.
- Melhorias do barco e estrelas por trecho, se os testes mostrarem que falta motivação para continuar.
Versão: rascunho inicial — a refinar conforme prototipagem e validação com educadores.