Projeto Hex — Guia Unity
Parte 2 — Guia de Implementação Unity
Guia fase a fase para implementar o MVP do Projeto Hex em Unity 6. Os números vêm da tabela de placeholders da Parte 1 §17 e ficam em um asset (
GameConfig), para dar para balancear sem mexer no código. As regras marcadas como Em aberto na Parte 1 viram opções (bool) doGameConfig, para trocar de ideia sem reescrever nada.
Convenções
- Nomes no projeto em inglês, comentários em português. Cada classe começa com
// POR QUE:e// ESTRATÉGIA:, e cada método tem um comentário curto acima dele. - Namespaces (troque
ProjetoHexpelo nome do jogo quando ele for decidido):ProjetoHex.Grid: matemática da grade hexagonal (hexágono, interseção, aresta, conversão para a tela).ProjetoHex.Data: tipos de recurso, ScriptableObjects de terreno e de configuração.ProjetoHex.Sim: o estado do mundo e as regras do jogo, em C# puro (semMonoBehaviour).ProjetoHex.View: desenhar o mapa, câmera e toque/clique no mapa.ProjetoHex.Meta:GameManager, save e progresso offline.ProjetoHex.UI: barra de recursos e painéis.
- Campos do Inspector:
[SerializeField] privatecom_camelCase. Dados de ScriptableObject e de save: campos públicoscamelCase. - Textos na tela sem emoji, porque a fonte padrão do TextMeshPro não tem emojis.
Arquitetura em uma página
Input (mouse/toque) GameManager (relógio + save) Tela
─────────────────── ──────────────────────────── ────
MapPointer ──Tapped──▶ ActionPanel ──▶ WorldActions ──▶ World ──Changed──▶ MapView / HexTileView
(pan, zoom, toque) (UI) (regras: ▲ ResourceBar (lê o estoque)
BuildRules, │
ScoutRules) │
Update ──▶ Simulation.Tick(dt) ────────────┤ (coleta + batedores)
RivalAI.Tick(dt) ───────────────┘ (mesmas WorldActions do jogador)
Awake ───▶ SaveSystem.TryLoad ──▶ tempo fora ──▶ vários Tick() seguidos
-
Regra de ouro: todo o jogo (mapa, estoque, construções, timers) mora no objeto
World, que é C# puro. Só três coisas mudam oWorld:WorldActions: ações do jogador e do rival, sempre checadas pelas regras.Simulation.Tick: a passagem do tempo.- O load do save.
A tela só lê o
Worlde redesenha quando ele avisa (Changed). -
Por que isso importa aqui: o progresso offline (Fase 7) é só rodar
Simulation.Tickvárias vezes seguidas, sem tela nenhuma. E o rival IA (Fase 6) usa as mesmasWorldActionsque o jogador, então nunca trapaceia nas regras.
Fase 0 — Setup do Projeto
Objetivo desta fase: um projeto Unity 6 2D vazio, organizado e com os pacotes certos, rodando no Play Mode.
0.1 Criar o projeto
- Instale o Unity Hub e uma versão Unity 6 LTS (6000.x). Marque os módulos Windows Build Support, Android Build Support, WebGL Build Support e, se tiver um Mac, iOS Build Support: o MVP é PC, Mobile e Web (Parte 1 §1).
- No Hub, crie um projeto com New project → Universal 2D e o nome
ProjetoHex.- É aqui que a dimensão 2D definida na Parte 1 vira decisão técnica: o template 2D já vem com URP 2D, sprites e câmera ortográfica.
- Isométrico não muda o template. Os hexágonos são sprites 2D comuns, e o "isométrico" vem de dois detalhes:
- a arte é desenhada em perspectiva;
- a grade é achatada na vertical (
isoYScale, Fase 1).
- Física não é usada: nada no jogo cai, colide ou anda por física. O toque no mapa é resolvido por matemática de hexágono (Fase 3), sem
Collider2D.
0.2 Estrutura de pastas
Crie dentro de Assets/:
Assets/_Project/
Scripts/
Grid/ (coordenadas hexagonais)
Data/ (recursos, terrenos, GameConfig)
Sim/ (World e regras, C# puro)
View/ (mapa na tela, câmera, toque)
Meta/ (GameManager, save)
UI/ (HUD e painéis)
Resources/ (só o GameConfig, ver Fase 2)
ScriptableObjects/
Terrains/
Prefabs/
Scenes/ (Game)
Sprites/
0.3 Pacotes (Window → Package Manager)
| Pacote | Para quê |
|---|---|
| Input System (já vem no Unity 6) | O mesmo código lê mouse (PC/Web) e toque (Mobile) através de Pointer. |
| 2D Sprite (vem com o template 2D) | Criar sprites placeholder de hexágono e círculo pelo menu Create → 2D → Sprites. |
| TextMeshPro (parte do pacote uGUI no Unity 6) | Textos da UI. No primeiro uso, aceite Import TMP Essentials. |
O 2D Tilemap Editor não é necessário. O Tilemap hexagonal do Unity sabe onde fica cada hexágono, mas não conhece interseções nem arestas, que são o centro do jogo (vilas nos cantos, estradas nos lados). Por isso a grade é feita em código (Fase 1) e cada hexágono é um SpriteRenderer comum.
0.4 Cena e ordenação de sprites
- Crie a cena
GameemAssets/_Project/Scenes/e adicione-a em File → Build Profiles → Scene List. - Sorting Layers (Edit → Project Settings → Tags and Layers → Sorting Layers), nesta ordem:
Terreno,Estradas,Construcoes,Marcadores. - Ordenar por altura na tela é o truque clássico do isométrico: o que está mais embaixo na tela fica na frente.
- Abra o asset Renderer 2D Data do projeto (em
Assets/Settings/, nome parecido comRenderer2D). - Em Transparency Sort Mode, escolha Custom Axis com Transparency Sort Axis = (0, 1, 0).
- Abra o asset Renderer 2D Data do projeto (em
- Em Edit → Project Settings → Player → Other Settings → Active Input Handling, deixe Input System Package (New) ou Both.
✅ Checkpoint da Fase 0
- O projeto abre sem erros no Console.
- A cena
Gameaparece na Scene List do Build Profile. - As 4 Sorting Layers existem na ordem certa.
- Apertar Play na cena vazia não gera erro.
Problemas comuns
- Não acho o Transparency Sort Mode: ele fica no asset Renderer 2D Data, não na câmera. Use a busca do Project com
t:Renderer2DDatapara achar. 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: Coordenadas Hexagonais, com a matemática de hexágono, interseção e aresta.
Fase 1 — Coordenadas Hexagonais
Objetivo desta fase: tipos que dão nome a cada hexágono, cada interseção e cada aresta do mapa, e que convertem essas posições para coordenadas da tela (já achatadas no isométrico).
Esta é a fase mais "matemática", mas é escrita uma vez e usada pelo resto do jogo. A referência clássica é o guia Hexagonal Grids da Red Blob Games, que vale a leitura.
O problema que ela resolve: no Catan, cada interseção é dividida por 3 hexágonos e cada aresta por 2. Se cada hexágono guardasse "meus 6 cantos" separadamente, a mesma vila apareceria em 3 lugares diferentes. Aqui cada canto e cada aresta tem um nome só.
1.1 Hexágono: HexCoord
// Caminho: Assets/_Project/Scripts/Grid/HexCoord.cs
using System;
using UnityEngine;
namespace ProjetoHex.Grid
{
// POR QUE: o mapa inteiro é feito de hexágonos, e quase toda regra do jogo
// pergunta "quem é vizinho de quem". Um tipo próprio para a posição de um
// hexágono deixa essas perguntas em uma linha só.
// ESTRATÉGIA: coordenadas axiais (q, r) com hexágonos "pointy-top" (ponta
// para cima): q cresce para a direita e r cresce para cima-direita. É um
// struct (tipo de valor, copiado ao passar adiante), com == e GetHashCode,
// então serve de chave em Dictionary e HashSet.
[Serializable]
public struct HexCoord : IEquatable<HexCoord>
{
// Campos públicos e minúsculos porque o JsonUtility (save da Fase 7)
// só grava campos, não propriedades.
public int q;
public int r;
public HexCoord(int q, int r)
{
this.q = q;
this.r = r;
}
// As 6 direções vizinhas, na ordem: L, NE, NO, O, SO, SE.
public static readonly HexCoord[] Directions =
{
new HexCoord(+1, 0), new HexCoord(0, +1), new HexCoord(-1, +1),
new HexCoord(-1, 0), new HexCoord(0, -1), new HexCoord(+1, -1),
};
// Vizinho na direção 0..5 (índice de Directions).
public HexCoord Neighbor(int direction) => this + Directions[direction];
// Quantos passos de hexágono em hexágono separam dois hexágonos.
public int DistanceTo(HexCoord other)
{
int dq = q - other.q;
int dr = r - other.r;
return (Mathf.Abs(dq) + Mathf.Abs(dr) + Mathf.Abs(dq + dr)) / 2;
}
// Os 6 cantos (interseções) deste hexágono, na ordem N, NE, SE, S, SO, NO.
// Os cantos se alternam entre "N" e "S" de algum hexágono: VertexCoord
// explica por quê.
public VertexCoord[] Corners()
{
return new[]
{
new VertexCoord(q, r, true), // N
new VertexCoord(q, r + 1, false), // NE = canto S do vizinho NE
new VertexCoord(q + 1, r - 1, true), // SE = canto N do vizinho SE
new VertexCoord(q, r, false), // S
new VertexCoord(q, r - 1, true), // SO = canto N do vizinho SO
new VertexCoord(q - 1, r + 1, false), // NO = canto S do vizinho NO
};
}
public static HexCoord operator +(HexCoord a, HexCoord b) => new HexCoord(a.q + b.q, a.r + b.r);
public static HexCoord operator *(HexCoord a, int k) => new HexCoord(a.q * k, a.r * k);
public static bool operator ==(HexCoord a, HexCoord b) => a.q == b.q && a.r == b.r;
public static bool operator !=(HexCoord a, HexCoord b) => !(a == b);
public bool Equals(HexCoord other) => this == other;
public override bool Equals(object obj) => obj is HexCoord other && this == other;
public override int GetHashCode() => HashCode.Combine(q, r);
public override string ToString() => $"Hex({q}, {r})";
}
}
1.2 Interseção: VertexCoord
// Caminho: Assets/_Project/Scripts/Grid/VertexCoord.cs
using System;
namespace ProjetoHex.Grid
{
// POR QUE: vilas e cidades ficam nas interseções (Parte 1 §4). Cada
// interseção é dividida por 3 hexágonos, e precisa de UM nome só, senão a
// mesma vila seria guardada 3 vezes.
// ESTRATÉGIA: num hexágono "pointy-top", toda interseção do mapa é ou a
// ponta de cima (N) ou a ponta de baixo (S) de exatamente um hexágono.
// Então uma interseção é "hexágono (q, r) + N ou S". Os outros 4 cantos de
// um hexágono são o N ou o S de algum vizinho (ver HexCoord.Corners).
[Serializable]
public struct VertexCoord : IEquatable<VertexCoord>
{
public int q;
public int r;
public bool north; // true = ponta de cima (N), false = ponta de baixo (S)
public VertexCoord(int q, int r, bool north)
{
this.q = q;
this.r = r;
this.north = north;
}
// O hexágono "dono" do nome desta interseção.
public HexCoord Hex => new HexCoord(q, r);
// Os 3 hexágonos que se encontram nesta interseção: é deles que uma
// vila aqui coleta.
public HexCoord[] TouchingHexes()
{
return north
? new[] { new HexCoord(q, r), new HexCoord(q - 1, r + 1), new HexCoord(q, r + 1) }
: new[] { new HexCoord(q, r), new HexCoord(q, r - 1), new HexCoord(q + 1, r - 1) };
}
// As 3 interseções ligadas a esta por uma aresta: usadas pela regra de
// distância e pelas estradas. Uma ponta N sempre liga a pontas S e vice-versa.
public VertexCoord[] AdjacentVertices()
{
return north
? new[] { new VertexCoord(q, r + 1, false), new VertexCoord(q - 1, r + 1, false), new VertexCoord(q - 1, r + 2, false) }
: new[] { new VertexCoord(q + 1, r - 1, true), new VertexCoord(q, r - 1, true), new VertexCoord(q + 1, r - 2, true) };
}
public static bool operator ==(VertexCoord a, VertexCoord b) => a.q == b.q && a.r == b.r && a.north == b.north;
public static bool operator !=(VertexCoord a, VertexCoord b) => !(a == b);
public bool Equals(VertexCoord other) => this == other;
public override bool Equals(object obj) => obj is VertexCoord other && this == other;
public override int GetHashCode() => HashCode.Combine(q, r, north);
public override string ToString() => $"Vertex({q}, {r}, {(north ? "N" : "S")})";
}
}
1.3 Aresta: EdgeCoord
// Caminho: Assets/_Project/Scripts/Grid/EdgeCoord.cs
using System;
using System.Collections.Generic;
namespace ProjetoHex.Grid
{
// POR QUE: estradas ficam nas arestas (Parte 1 §4). Uma aresta liga duas
// interseções vizinhas e precisa do mesmo nome venha de que lado vier.
// ESTRATÉGIA: toda aresta liga uma ponta N a uma ponta S. O construtor
// sempre guarda a ponta N em "a" e a S em "b", então (x, y) e (y, x) viram
// a mesma aresta e funcionam como a mesma chave de Dictionary.
[Serializable]
public struct EdgeCoord : IEquatable<EdgeCoord>
{
public VertexCoord a; // sempre a ponta N
public VertexCoord b; // sempre a ponta S
// Monta a aresta entre duas interseções vizinhas, em qualquer ordem.
public EdgeCoord(VertexCoord x, VertexCoord y)
{
if (x.north == y.north)
throw new ArgumentException("Uma aresta sempre liga uma ponta N a uma ponta S.");
a = x.north ? x : y;
b = x.north ? y : x;
}
// Os 2 hexágonos dos dois lados da aresta: os que as duas pontas têm em comum.
public HexCoord[] TouchingHexes()
{
var result = new List<HexCoord>(2);
HexCoord[] fromB = b.TouchingHexes();
foreach (HexCoord h in a.TouchingHexes())
if (Array.IndexOf(fromB, h) >= 0)
result.Add(h);
return result.ToArray();
}
public static bool operator ==(EdgeCoord x, EdgeCoord y) => x.a == y.a && x.b == y.b;
public static bool operator !=(EdgeCoord x, EdgeCoord y) => !(x == y);
public bool Equals(EdgeCoord other) => this == other;
public override bool Equals(object obj) => obj is EdgeCoord other && this == other;
public override int GetHashCode() => HashCode.Combine(a, b);
public override string ToString() => $"Edge({a} - {b})";
}
}
1.4 Da grade para a tela: HexLayout
// Caminho: Assets/_Project/Scripts/Grid/HexLayout.cs
using UnityEngine;
namespace ProjetoHex.Grid
{
// POR QUE: a grade só sabe "hexágono (2, -1)". Para desenhar, e para saber
// em que hexágono o jogador tocou, é preciso converter isso de e para
// posições do mundo do Unity.
// ESTRATÉGIA: fórmulas padrão de hexágono pointy-top, com uma diferença: o
// eixo Y é multiplicado por yScale (< 1). É esse "achatamento" vertical que
// faz a grade parecer vista de cima em ângulo (isométrico). O valor certo
// depende da arte (Parte 1 §12, Em aberto).
public class HexLayout
{
private const float Sqrt3 = 1.7320508f;
public readonly float size; // distância do centro do hexágono até uma ponta, em unidades
public readonly float yScale; // achatamento vertical do isométrico (1 = visto de cima)
public HexLayout(float size, float yScale)
{
this.size = size;
this.yScale = yScale;
}
// Centro do hexágono no mundo.
public Vector3 HexToWorld(HexCoord h)
{
float x = size * Sqrt3 * (h.q + h.r * 0.5f);
float y = size * 1.5f * h.r * yScale;
return new Vector3(x, y, 0f);
}
// Posição de uma interseção: centro do hexágono dono + ponta N ou S.
public Vector3 VertexToWorld(VertexCoord v)
{
Vector3 center = HexToWorld(v.Hex);
return center + new Vector3(0f, (v.north ? size : -size) * yScale, 0f);
}
// Meio da aresta: média das duas pontas.
public Vector3 EdgeToWorld(EdgeCoord e) => (VertexToWorld(e.a) + VertexToWorld(e.b)) * 0.5f;
// Qual hexágono contém este ponto do mundo (inverso de HexToWorld).
public HexCoord WorldToHex(Vector3 world)
{
float x = world.x;
float y = world.y / yScale; // desfaz o achatamento antes da fórmula
float q = (Sqrt3 / 3f * x - 1f / 3f * y) / size;
float r = (2f / 3f * y) / size;
return Round(q, r);
}
// Arredonda uma coordenada fracionária para o hexágono mais próximo.
// Arredondar q e r separadamente erra perto das bordas; o truque é usar
// a terceira coordenada s = -q - r e corrigir a que mais arredondou.
private static HexCoord Round(float fq, float fr)
{
float fs = -fq - fr;
int q = Mathf.RoundToInt(fq);
int r = Mathf.RoundToInt(fr);
int s = Mathf.RoundToInt(fs);
float dq = Mathf.Abs(q - fq);
float dr = Mathf.Abs(r - fr);
float ds = Mathf.Abs(s - fs);
if (dq > dr && dq > ds) q = -r - s;
else if (dr > ds) r = -q - s;
return new HexCoord(q, r);
}
}
}
1.5 Teste rápido no Console
Ainda não há nada na tela, então teste a matemática com um script temporário. Coloque-o num GameObject vazio da cena Game e apague-o depois da Fase 3.
// Caminho: Assets/_Project/Scripts/Grid/HexGridSmokeTest.cs (temporário)
using UnityEngine;
namespace ProjetoHex.Grid
{
// POR QUE: erro de sinal em coordenada hexagonal é fácil de cometer e
// difícil de ver. Este teste confere as propriedades que o resto do jogo
// assume antes de existir qualquer tela.
// ESTRATÉGIA: checagens simples com Debug.Assert, que mostra erro no
// Console se a condição for falsa.
public class HexGridSmokeTest : MonoBehaviour
{
// Start roda uma vez quando a cena começa (depois de todos os Awake).
private void Start()
{
var layout = new HexLayout(1f, 0.6f);
var h = new HexCoord(2, -1);
// Ida e volta: centro do hexágono -> mundo -> hexágono.
Debug.Assert(layout.WorldToHex(layout.HexToWorld(h)) == h, "WorldToHex falhou");
// Cada canto toca 3 hexágonos, e um deles é o próprio h.
foreach (VertexCoord v in h.Corners())
Debug.Assert(System.Array.IndexOf(v.TouchingHexes(), h) >= 0, $"{v} não toca {h}");
// Vizinhança é simétrica: se A liga em B, B liga em A.
foreach (VertexCoord v in h.Corners())
foreach (VertexCoord n in v.AdjacentVertices())
Debug.Assert(System.Array.IndexOf(n.AdjacentVertices(), v) >= 0, $"{v} e {n} não são simétricos");
// Toda aresta divide exatamente 2 hexágonos.
var e = new EdgeCoord(h.Corners()[0], h.Corners()[1]);
Debug.Assert(e.TouchingHexes().Length == 2, "Aresta deveria tocar 2 hexágonos");
Debug.Log("HexGridSmokeTest terminou. Se não apareceu nenhum erro acima, a grade está certa.");
}
}
}
✅ Checkpoint da Fase 1
- Os 4 scripts da grade compilam sem erros.
- Ao apertar Play com o
HexGridSmokeTestna cena, só aparece a mensagem final, sem nenhum "Assertion failed".
Problemas comuns
- "Assertion failed: X e Y não são simétricos": algum índice de
AdjacentVerticesou deCornersfoi digitado errado. Compare com a listagem: os sinais deqerimportam. HashCodenão encontrado: falta ousing System;no topo do arquivo.
Próxima fase: Dados e Mundo, com os recursos, os terrenos, o GameConfig e o objeto World com o mapa gerado.
Fase 2 — Dados e Mundo
Objetivo desta fase: os terrenos e os números do jogo editáveis no Inspector, e um
Worldem C# puro com o mapa gerado por seed, a vila inicial do jogador e a do rival.
2.1 Recursos
// Caminho: Assets/_Project/Scripts/Data/ResourceType.cs
using System;
namespace ProjetoHex.Data
{
// POR QUE: os 5 recursos do MVP (Parte 1 §3). Um enum evita erro de digitação
// ("trigo" vs "Trigo") e serve de índice no array do estoque.
// ESTRATÉGIA: o número de cada valor é fixo (= 0, = 1...) porque o save guarda
// o estoque por posição. Não reordene: acrescente novos recursos no fim.
public enum ResourceType
{
Minerio = 0,
Trigo = 1,
La = 2,
Argila = 3,
Peixe = 4,
}
// POR QUE: um par "recurso + quantidade", usado em custos e no estoque inicial.
// ESTRATÉGIA: [Serializable] faz o Unity mostrar a lista de custos no Inspector.
[Serializable]
public struct ResourceAmount
{
public ResourceType type;
public int amount;
public ResourceAmount(ResourceType type, int amount)
{
this.type = type;
this.amount = amount;
}
}
// POR QUE: textos com acento para a UI ("Minério", "Lã"), que o nome do enum não pode ter.
// ESTRATÉGIA: funções estáticas pequenas, usadas pela UI e pelo painel de ação.
public static class ResourceTypes
{
// Quantidade de recursos. Atualize se acrescentar um valor no enum.
public const int Count = 5;
// Nome do recurso para a tela.
public static string Label(ResourceType type) => type switch
{
ResourceType.Minerio => "Minério",
ResourceType.Trigo => "Trigo",
ResourceType.La => "Lã",
ResourceType.Argila => "Argila",
ResourceType.Peixe => "Peixe",
_ => type.ToString(),
};
// Custo em texto, ex.: "1 Argila, 1 Lã".
public static string Format(ResourceAmount[] cost)
{
if (cost == null || cost.Length == 0) return "grátis";
var parts = new string[cost.Length];
for (int i = 0; i < cost.Length; i++)
parts[i] = $"{cost[i].amount} {Label(cost[i].type)}";
return string.Join(", ", parts);
}
}
}
2.2 Terreno: TerrainDef
// Caminho: Assets/_Project/Scripts/Data/TerrainDef.cs
using UnityEngine;
namespace ProjetoHex.Data
{
// POR QUE: cada tipo de hexágono (mina, trigo, pasto, barreiro, rio) tem
// recurso, tempo de ciclo, raridade e arte próprios (Parte 1 §3). Um terreno
// novo deve ser "criar um asset", não "mexer em código".
// ESTRATÉGIA: ScriptableObject é um arquivo de dados do Unity (.asset)
// editável no Inspector. [CreateAssetMenu] adiciona a opção no menu
// Create do Project.
[CreateAssetMenu(fileName = "Terreno", menuName = "Projeto Hex/Terreno")]
public class TerrainDef : ScriptableObject
{
public string displayName = "Campo de trigo";
public ResourceType resource = ResourceType.Trigo;
[Tooltip("Segundos para produzir um ciclo (Parte 1 §5).")]
[Min(1f)] public float cycleSeconds = 20f;
[Tooltip("Quanto uma vila recebe por ciclo (a cidade recebe o dobro, ver GameConfig).")]
[Min(1)] public int amountPerCycle = 1;
[Tooltip("Chance relativa de aparecer no mapa. 2 = aparece o dobro de um terreno com 1.")]
[Min(0f)] public float spawnWeight = 1f;
[Tooltip("Arte do hexágono. Vazio = usa o hexágono placeholder tingido com a cor abaixo.")]
public Sprite sprite;
public Color placeholderColor = Color.yellow;
}
}
2.3 Configuração: GameConfig
// Caminho: Assets/_Project/Scripts/Data/GameConfig.cs
using UnityEngine;
namespace ProjetoHex.Data
{
// POR QUE: todos os números de balanceamento (Parte 1 §17) e as regras ainda
// Em aberto (Parte 1 §4) num lugar só, editáveis sem recompilar.
// ESTRATÉGIA: um único asset em Resources/, carregado por nome com
// Resources.Load. Os valores iniciais dos campos já são os placeholders, então
// o asset novo nasce preenchido.
[CreateAssetMenu(fileName = "GameConfig", menuName = "Projeto Hex/Game Config")]
public class GameConfig : ScriptableObject
{
[Header("Mapa (Parte 1 §3)")]
[Min(2)] public int mapRadius = 7;
[Tooltip("0 = seed aleatória a cada jogo novo. Outro número = sempre o mesmo mapa (bom para testar).")]
public int fixedSeed = 0;
public TerrainDef[] terrains;
[Header("Visual (Parte 1 §12)")]
public float hexSize = 1f;
[Tooltip("Achatamento vertical do isométrico. Ajuste até bater com a arte.")]
[Range(0.3f, 1f)] public float isoYScale = 0.6f;
[Header("Início")]
public ResourceAmount[] startingResources =
{
new ResourceAmount(ResourceType.Argila, 3), new ResourceAmount(ResourceType.Minerio, 2),
new ResourceAmount(ResourceType.La, 2), new ResourceAmount(ResourceType.Trigo, 3),
new ResourceAmount(ResourceType.Peixe, 2),
};
[Min(0)] public int startingScouts = 1;
[Header("Produção (Parte 1 §5)")]
[Min(0)] public int villageYield = 1;
[Min(0)] public int cityYield = 2;
[Header("Custos (Parte 1 §17)")]
public ResourceAmount[] villageCost =
{
new ResourceAmount(ResourceType.Argila, 1), new ResourceAmount(ResourceType.La, 1),
new ResourceAmount(ResourceType.Trigo, 1), new ResourceAmount(ResourceType.Peixe, 1),
};
public ResourceAmount[] cityCost =
{
new ResourceAmount(ResourceType.Minerio, 3), new ResourceAmount(ResourceType.Trigo, 2),
};
public ResourceAmount[] roadCost =
{
new ResourceAmount(ResourceType.Argila, 1), new ResourceAmount(ResourceType.Minerio, 1),
};
public ResourceAmount[] scoutCost =
{
new ResourceAmount(ResourceType.Trigo, 1), new ResourceAmount(ResourceType.Peixe, 1),
};
[Header("Regras Em aberto (Parte 1 §4), placeholders")]
[Tooltip("Não pode construir colado em outra construção (regra do Catan).")]
public bool distanceRule = true;
[Tooltip("Vila só onde chega uma estrada sua (regra do Catan).")]
public bool requireRoadConnection = true;
[Header("Batedores (Parte 1 §6)")]
[Min(1f)] public float scoutSeconds = 60f;
[Min(1)] public int maxScouts = 3;
[Header("Offline e save (Parte 1 §10)")]
[Min(0f)] public float offlineCapHours = 8f;
[Min(5f)] public float autosaveSeconds = 30f;
[Header("Rival IA (Parte 1 §9)")]
public bool rivalEnabled = true;
[Min(1f)] public float rivalThinkSeconds = 20f;
[Min(2)] public int rivalStartDistance = 5;
// Carrega o asset Assets/_Project/Resources/GameConfig.asset.
public static GameConfig Load()
{
var config = Resources.Load<GameConfig>("GameConfig");
if (config == null)
Debug.LogError("GameConfig não encontrado. Crie em Assets/_Project/Resources/GameConfig.asset (Fase 2).");
return config;
}
}
}
2.4 O estado do jogo: World
// Caminho: Assets/_Project/Scripts/Sim/World.cs
using System;
using System.Collections.Generic;
using ProjetoHex.Data;
using ProjetoHex.Grid;
using UnityEngine;
namespace ProjetoHex.Sim
{
// POR QUE: um hexágono do mapa: onde está, que terreno é e quanto falta para
// o próximo ciclo de produção (Parte 1 §5).
// ESTRATÉGIA: o terreno nunca muda depois de gerado (readonly); só o
// progresso do relógio muda.
public class HexCell
{
public readonly HexCoord coord;
public readonly int terrainIndex; // posição em GameConfig.terrains
public readonly TerrainDef terrain;
public float progress; // segundos acumulados no ciclo atual
public HexCell(HexCoord coord, int terrainIndex, TerrainDef terrain)
{
this.coord = coord;
this.terrainIndex = terrainIndex;
this.terrain = terrain;
}
// Progresso do ciclo de 0 a 1, para a barrinha na tela.
public float Progress01 => Mathf.Clamp01(progress / terrain.cycleSeconds);
}
// POR QUE: uma vila ou cidade numa interseção (Parte 1 §4).
// ESTRATÉGIA: [Serializable] com campos públicos para ir direto para o save.
[Serializable]
public class Building
{
public const int Village = 1;
public const int City = 2;
public VertexCoord vertex;
public int owner; // World.Player ou World.Rival
public int level; // Village ou City
}
// POR QUE: uma estrada numa aresta (Parte 1 §4).
[Serializable]
public class Road
{
public EdgeCoord edge;
public int owner;
}
// POR QUE: um batedor em missão (Parte 1 §6): para onde foi e quanto falta.
[Serializable]
public class ScoutJob
{
public HexCoord target;
public float remaining; // segundos
}
// POR QUE: tudo que pertence a um jogador (você ou o rival): estoque, o que
// ele já explorou e seus batedores.
// ESTRATÉGIA: a névoa é por jogador (Parte 1 §9), então cada Faction tem o
// seu conjunto "revealed". O estoque é um array indexado pelo ResourceType.
public class Faction
{
public readonly int id;
public readonly int[] bank = new int[ResourceTypes.Count];
public readonly HashSet<HexCoord> revealed = new HashSet<HexCoord>();
public readonly List<ScoutJob> scoutJobs = new List<ScoutJob>();
public int scoutsOwned;
public Faction(int id)
{
this.id = id;
}
// Batedores parados em casa, prontos para uma missão.
public int IdleScouts => scoutsOwned - scoutJobs.Count;
// Quanto tem de um recurso.
public int Get(ResourceType type) => bank[(int)type];
// Soma (ou subtrai, com valor negativo) um recurso.
public void Add(ResourceType type, int amount) => bank[(int)type] += amount;
// Tem tudo que o custo pede?
public bool CanAfford(ResourceAmount[] cost)
{
foreach (ResourceAmount c in cost)
if (bank[(int)c.type] < c.amount) return false;
return true;
}
// Desconta o custo. Chame só depois de CanAfford.
public void Pay(ResourceAmount[] cost)
{
foreach (ResourceAmount c in cost)
bank[(int)c.type] -= c.amount;
}
// Já tem um batedor indo para este hexágono?
public bool IsScouting(HexCoord target)
{
foreach (ScoutJob job in scoutJobs)
if (job.target == target) return true;
return false;
}
}
// POR QUE: o jogo inteiro num objeto só, sem nada de Unity: mapa, jogadores,
// construções e estradas. A tela, o rival IA, o save e o progresso offline
// leem e mudam só isto (ver "Arquitetura em uma página").
// ESTRATÉGIA: Dictionary por coordenada para responder rápido "tem algo
// aqui?". O evento Changed avisa a tela quando algo estrutural muda
// (construção, estrada, hexágono revelado). O estoque muda o tempo todo e
// não dispara o evento: a UI lê a cada frame.
public class World
{
public const int Player = 0;
public const int Rival = 1;
public readonly GameConfig config;
public readonly int seed;
public readonly Dictionary<HexCoord, HexCell> hexes = new Dictionary<HexCoord, HexCell>();
public readonly List<HexCell> hexList = new List<HexCell>(); // ordem fixa da geração (o save usa)
public readonly Faction[] factions = { new Faction(Player), new Faction(Rival) };
public readonly Dictionary<VertexCoord, Building> buildings = new Dictionary<VertexCoord, Building>();
public readonly Dictionary<EdgeCoord, Road> roads = new Dictionary<EdgeCoord, Road>();
public VertexCoord playerStart;
public VertexCoord rivalStart;
public float rivalThinkTimer;
public event Action Changed;
public World(GameConfig config, int seed)
{
this.config = config;
this.seed = seed;
}
// O hexágono existe no mapa?
public bool InMap(HexCoord h) => hexes.ContainsKey(h);
// A interseção encosta em pelo menos um hexágono do mapa?
public bool VertexInMap(VertexCoord v)
{
foreach (HexCoord h in v.TouchingHexes())
if (InMap(h)) return true;
return false;
}
// Hexágono na névoa, mas encostado num já revelado: dá para mandar batedor.
public bool IsFrontier(int owner, HexCoord h)
{
Faction f = factions[owner];
if (!InMap(h) || f.revealed.Contains(h)) return false;
for (int d = 0; d < 6; d++)
if (f.revealed.Contains(h.Neighbor(d))) return true;
return false;
}
// Revela todos os hexágonos do mapa até "radius" passos de "center".
public void RevealAround(int owner, HexCoord center, int radius)
{
for (int dq = -radius; dq <= radius; dq++)
{
int rMin = Mathf.Max(-radius, -dq - radius);
int rMax = Mathf.Min(radius, -dq + radius);
for (int dr = rMin; dr <= rMax; dr++)
{
var h = new HexCoord(center.q + dq, center.r + dr);
if (InMap(h)) factions[owner].revealed.Add(h);
}
}
}
// Avisa quem estiver ouvindo (a tela) que algo estrutural mudou.
public void NotifyChanged() => Changed?.Invoke();
}
}
2.5 Gerar o mapa e começar um jogo: WorldFactory
// Caminho: Assets/_Project/Scripts/Sim/WorldFactory.cs
using System;
using System.Collections.Generic;
using ProjetoHex.Data;
using ProjetoHex.Grid;
using UnityEngine;
namespace ProjetoHex.Sim
{
// POR QUE: criar o mapa e montar o começo do jogo (vila inicial, estoque,
// primeiro batedor) para o jogador e o rival.
// ESTRATÉGIA: o terreno é gerado só a partir da seed, sempre igual para a
// mesma seed. Assim o save (Fase 7) não precisa guardar o terreno de 169
// hexágonos: guarda a seed e gera de novo. Generate() faz só a parte
// determinística; NewGame() acrescenta o estado inicial dos jogadores.
public static class WorldFactory
{
// Jogo novo do zero.
public static World NewGame(GameConfig config)
{
int seed = config.fixedSeed != 0 ? config.fixedSeed : Environment.TickCount;
World world = Generate(config, seed);
SetupFaction(world, World.Player, world.playerStart);
if (config.rivalEnabled)
SetupFaction(world, World.Rival, world.rivalStart);
return world;
}
// Gera o terreno e os pontos de partida a partir da seed (determinístico).
public static World Generate(GameConfig config, int seed)
{
var world = new World(config, seed);
var rng = new System.Random(seed);
int radius = config.mapRadius;
// 1. Sorteia o terreno de cada hexágono de um mapa em forma de hexágono.
var coords = new List<HexCoord>();
var terrainOf = new Dictionary<HexCoord, int>();
for (int q = -radius; q <= radius; q++)
{
int rMin = Mathf.Max(-radius, -q - radius);
int rMax = Mathf.Min(radius, -q + radius);
for (int r = rMin; r <= rMax; r++)
{
var h = new HexCoord(q, r);
coords.Add(h);
terrainOf[h] = PickTerrain(config, rng);
}
}
// 2. Pontos de partida: jogador no centro, rival numa direção sorteada.
// O "- 1" garante que os 3 hexágonos da vila do rival estão dentro do mapa.
world.playerStart = new VertexCoord(0, 0, true);
int distance = Mathf.Min(config.rivalStartDistance, radius - 1);
HexCoord rivalHex = HexCoord.Directions[rng.Next(6)] * distance;
world.rivalStart = new VertexCoord(rivalHex.q, rivalHex.r, true);
// 3. Início justo (placeholder, Parte 1 §3): 3 terrenos diferentes em volta de cada vila.
MakeFairStart(config, world.playerStart, terrainOf, rng);
MakeFairStart(config, world.rivalStart, terrainOf, rng);
// 4. Cria as células numa ordem fixa (a mesma lista "coords"), que o save usa.
foreach (HexCoord h in coords)
{
int t = terrainOf[h];
var cell = new HexCell(h, t, config.terrains[t]);
world.hexes[h] = cell;
world.hexList.Add(cell);
}
return world;
}
// Sorteio com peso: um terreno com spawnWeight 2 sai o dobro de um com 1.
private static int PickTerrain(GameConfig config, System.Random rng)
{
float total = 0f;
foreach (TerrainDef t in config.terrains) total += t.spawnWeight;
double roll = rng.NextDouble() * total;
for (int i = 0; i < config.terrains.Length; i++)
{
roll -= config.terrains[i].spawnWeight;
if (roll < 0) return i;
}
return config.terrains.Length - 1;
}
// Troca os 3 hexágonos em volta da vila por 3 terrenos diferentes, embaralhados.
private static void MakeFairStart(GameConfig config, VertexCoord start, Dictionary<HexCoord, int> terrainOf, System.Random rng)
{
int count = config.terrains.Length;
if (count < 3) return;
var order = new List<int>();
for (int i = 0; i < count; i++) order.Add(i);
for (int i = count - 1; i > 0; i--) // embaralhamento de Fisher-Yates
{
int j = rng.Next(i + 1);
(order[i], order[j]) = (order[j], order[i]);
}
HexCoord[] touching = start.TouchingHexes();
for (int i = 0; i < touching.Length; i++)
if (terrainOf.ContainsKey(touching[i]))
terrainOf[touching[i]] = order[i];
}
// Estoque inicial, batedores, vila inicial e área em volta já revelada.
private static void SetupFaction(World world, int owner, VertexCoord start)
{
Faction f = world.factions[owner];
foreach (ResourceAmount a in world.config.startingResources) f.Add(a.type, a.amount);
f.scoutsOwned = world.config.startingScouts;
world.buildings[start] = new Building { vertex = start, owner = owner, level = Building.Village };
foreach (HexCoord h in start.TouchingHexes())
world.RevealAround(owner, h, 1);
}
}
}
2.6 Criar os assets
- Em
Assets/_Project/ScriptableObjects/Terrains/, use Create → Projeto Hex → Terreno para criar os 5 terrenos da Parte 1 §17:
| Asset | displayName | resource | cycleSeconds | spawnWeight | placeholderColor |
|---|---|---|---|---|---|
Mina | Mina | Minerio | 45 | 1 | cinza |
Trigo | Campo de trigo | Trigo | 20 | 1.2 | amarelo |
Pasto | Pasto | La | 25 | 1.2 | verde-claro |
Barreiro | Barreiro | Argila | 30 | 1 | laranja-tijolo |
Rio | Rio | Peixe | 30 | 0.7 | azul |
Os pesos (spawnWeight) também são placeholders.
- Em
Assets/_Project/Resources/, use Create → Projeto Hex → Game Config, com o nome exatoGameConfig. Arraste os 5 terrenos para a lista Terrains. Os outros campos já vêm com os placeholders. - Para testar sempre o mesmo mapa enquanto desenvolve, coloque Fixed Seed = 1234. Volte para 0 antes do build.
✅ Checkpoint da Fase 2
- Os scripts compilam sem erros.
GameConfigexiste emResources/com os 5 terrenos na lista.- Ainda não há nada para ver na tela: o
Worldaparece pela primeira vez na Fase 3.
Problemas comuns
IndexOutOfRangeExceptionemGenerate: a lista Terrains doGameConfigestá vazia ou tem um itemNone. Preencha os 5.- Mudar a ordem da lista Terrains muda o mapa salvo: o save guarda o terreno pelo índice da lista (Fase 7). Durante o desenvolvimento, acrescente terrenos novos no fim e apague o save se reordenar.
Próxima fase: Mapa na Tela e Câmera, com os hexágonos desenhados, a névoa, a câmera arrastável e o toque que identifica hexágono, interseção ou aresta.
Fase 3 — Mapa na Tela e Câmera
Objetivo desta fase: o mapa aparece na tela com névoa, vila do jogador e barrinhas de produção; dá para arrastar e dar zoom no mouse e no toque; tocar no mapa diz no Console se foi num hexágono, numa interseção ou numa aresta.
3.1 GameManager (versão 1)
O GameManager cresce ao longo do guia: aqui ele só cria o mundo. A Fase 4 acrescenta o relógio, a Fase 6 o rival e a Fase 7 traz a versão completa, com save.
// Caminho: Assets/_Project/Scripts/Meta/GameManager.cs (versão 1, substituída na Fase 7)
using ProjetoHex.Data;
using ProjetoHex.Grid;
using ProjetoHex.Sim;
using UnityEngine;
namespace ProjetoHex.Meta
{
// POR QUE: o único MonoBehaviour que "é dono" do World. Os outros scripts da
// cena pegam o mundo daqui (GameManager.Instance.World).
// ESTRATÉGIA: um singleton simples (Instance estático). [DefaultExecutionOrder(-100)]
// faz o Awake dele rodar antes do Awake/Start dos outros scripts, então o World
// já existe quando a tela vai desenhar.
[DefaultExecutionOrder(-100)]
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
public GameConfig Config { get; private set; }
public HexLayout Layout { get; private set; }
public World World { get; private set; }
// Awake roda quando o objeto é criado, antes de qualquer Start.
private void Awake()
{
Instance = this;
Config = GameConfig.Load();
Layout = new HexLayout(Config.hexSize, Config.isoYScale);
World = WorldFactory.NewGame(Config);
}
}
}
3.2 Um hexágono na tela: HexTileView
// Caminho: Assets/_Project/Scripts/View/HexTileView.cs
using ProjetoHex.Sim;
using UnityEngine;
namespace ProjetoHex.View
{
// POR QUE: a parte visual de um hexágono: o terreno, a névoa por cima, a
// barrinha do ciclo de produção e o marcador de "batedor a caminho".
// ESTRATÉGIA: um prefab com SpriteRenderers filhos que só ligam e desligam.
// Quem decide o estado é o MapView; este script só aplica.
public class HexTileView : MonoBehaviour
{
[SerializeField] private SpriteRenderer _terrain;
[SerializeField] private SpriteRenderer _fog;
[SerializeField] private GameObject _progressRoot;
[SerializeField] private Transform _progressFill; // pivô na ponta esquerda da barra
[SerializeField] private GameObject _scoutMarker;
[SerializeField] private Color _fogColor = new Color(0.12f, 0.12f, 0.16f);
[SerializeField] private Color _frontierFogColor = new Color(0.32f, 0.32f, 0.42f);
public HexCell Cell { get; private set; }
// Liga este visual a uma célula do mundo e pinta o terreno.
public void Setup(HexCell cell)
{
Cell = cell;
if (cell.terrain.sprite != null)
{
_terrain.sprite = cell.terrain.sprite;
_terrain.color = Color.white;
}
else
{
_terrain.color = cell.terrain.placeholderColor;
}
name = $"{cell.coord} {cell.terrain.displayName}";
}
// Aplica o estado: revelado ou na névoa, fronteira (névoa mais clara =
// "dá para explorar"), produzindo para o jogador, batedor a caminho.
public void SetState(bool revealed, bool frontier, bool producing, bool scouted)
{
_terrain.enabled = revealed;
_fog.enabled = !revealed;
_fog.color = frontier ? _frontierFogColor : _fogColor;
_progressRoot.SetActive(revealed && producing);
_scoutMarker.SetActive(scouted);
}
// Estica a barrinha conforme o ciclo avança (chamado a cada frame pelo MapView).
public void UpdateProgress()
{
Vector3 s = _progressFill.localScale;
s.x = Cell.Progress01;
_progressFill.localScale = s;
}
}
}
3.3 O mapa inteiro: MapView
// Caminho: Assets/_Project/Scripts/View/MapView.cs
using System.Collections.Generic;
using ProjetoHex.Grid;
using ProjetoHex.Meta;
using ProjetoHex.Sim;
using UnityEngine;
namespace ProjetoHex.View
{
// POR QUE: transformar o World em coisas na tela: um HexTileView por
// hexágono, um sprite por vila/cidade, uma linha por estrada.
// ESTRATÉGIA: os hexágonos são criados uma vez no Start. Construções e
// estradas são criadas quando aparecem pela primeira vez e guardadas em
// Dictionary, para não recriar tudo a cada mudança. Refresh() roda quando o
// World avisa (evento Changed); só as barrinhas de progresso atualizam a cada frame.
public class MapView : MonoBehaviour
{
[SerializeField] private HexTileView _hexPrefab;
[SerializeField] private SpriteRenderer _buildingPrefab;
[SerializeField] private LineRenderer _roadPrefab;
[SerializeField] private Sprite _villageSprite;
[SerializeField] private Sprite _citySprite;
[SerializeField] private float _cityScale = 1.4f; // placeholder: cidade = vila maior
[SerializeField] private float _roadWidth = 0.12f;
[SerializeField] private Color[] _factionColors = { new Color(0.2f, 0.5f, 1f), new Color(0.9f, 0.25f, 0.2f) };
private World _world;
private HexLayout _layout;
private readonly Dictionary<HexCoord, HexTileView> _tiles = new Dictionary<HexCoord, HexTileView>();
private readonly Dictionary<VertexCoord, SpriteRenderer> _buildingViews = new Dictionary<VertexCoord, SpriteRenderer>();
private readonly Dictionary<EdgeCoord, LineRenderer> _roadViews = new Dictionary<EdgeCoord, LineRenderer>();
private readonly List<HexTileView> _producingTiles = new List<HexTileView>();
// Cria um HexTileView para cada hexágono e começa a ouvir o World.
private void Start()
{
_world = GameManager.Instance.World;
_layout = GameManager.Instance.Layout;
foreach (HexCell cell in _world.hexList)
{
HexTileView tile = Instantiate(_hexPrefab, _layout.HexToWorld(cell.coord), Quaternion.identity, transform);
tile.Setup(cell);
_tiles[cell.coord] = tile;
}
_world.Changed += Refresh;
Refresh();
}
// Para de ouvir o World quando a cena fecha (evita chamar um objeto destruído).
private void OnDestroy()
{
if (_world != null) _world.Changed -= Refresh;
}
// Barrinhas de produção andam a cada frame.
private void Update()
{
foreach (HexTileView tile in _producingTiles) tile.UpdateProgress();
}
// Redesenha névoa, construções e estradas a partir do World.
private void Refresh()
{
Faction player = _world.factions[World.Player];
// Hexágonos que produzem para o jogador = os que tocam uma construção dele.
var producing = new HashSet<HexCoord>();
foreach (Building b in _world.buildings.Values)
if (b.owner == World.Player)
foreach (HexCoord h in b.vertex.TouchingHexes()) producing.Add(h);
_producingTiles.Clear();
foreach (KeyValuePair<HexCoord, HexTileView> pair in _tiles)
{
HexCoord c = pair.Key;
bool revealed = player.revealed.Contains(c);
bool isProducing = revealed && producing.Contains(c);
pair.Value.SetState(revealed, _world.IsFrontier(World.Player, c), isProducing, player.IsScouting(c));
if (isProducing) _producingTiles.Add(pair.Value);
}
foreach (Building b in _world.buildings.Values) RefreshBuilding(b, player);
foreach (Road road in _world.roads.Values) RefreshRoad(road, player);
}
// O jogador só vê o que encosta em algo que ele já explorou (névoa por jogador, Parte 1 §9).
private static bool PlayerSees(HexCoord[] touching, Faction player)
{
foreach (HexCoord h in touching)
if (player.revealed.Contains(h)) return true;
return false;
}
// Cria (se ainda não existe) e atualiza o sprite de uma vila/cidade.
private void RefreshBuilding(Building b, Faction player)
{
if (!_buildingViews.TryGetValue(b.vertex, out SpriteRenderer view))
{
view = Instantiate(_buildingPrefab, _layout.VertexToWorld(b.vertex), Quaternion.identity, transform);
_buildingViews[b.vertex] = view;
}
Sprite sprite = b.level == Building.City ? _citySprite : _villageSprite;
if (sprite != null) view.sprite = sprite;
view.color = _factionColors[b.owner];
view.transform.localScale = Vector3.one * (b.level == Building.City ? _cityScale : 1f);
view.gameObject.SetActive(PlayerSees(b.vertex.TouchingHexes(), player));
}
// Cria (se ainda não existe) e atualiza a linha de uma estrada.
private void RefreshRoad(Road road, Faction player)
{
if (!_roadViews.TryGetValue(road.edge, out LineRenderer line))
{
line = Instantiate(_roadPrefab, transform);
line.useWorldSpace = true;
line.positionCount = 2;
line.SetPosition(0, _layout.VertexToWorld(road.edge.a));
line.SetPosition(1, _layout.VertexToWorld(road.edge.b));
line.startWidth = _roadWidth;
line.endWidth = _roadWidth;
_roadViews[road.edge] = line;
}
line.startColor = _factionColors[road.owner];
line.endColor = _factionColors[road.owner];
line.gameObject.SetActive(PlayerSees(road.edge.TouchingHexes(), player));
}
}
}
3.4 O que foi tocado: MapTarget
// Caminho: Assets/_Project/Scripts/View/MapTarget.cs
using ProjetoHex.Grid;
using UnityEngine;
namespace ProjetoHex.View
{
// POR QUE: um toque no mapa pode cair num hexágono (explorar/ver produção),
// numa interseção (vila/cidade) ou numa aresta (estrada). O painel de ação
// (Fase 5) precisa saber qual dos três foi.
// ESTRATÉGIA: um struct com o tipo e as três coordenadas possíveis; só a do
// tipo escolhido é usada.
public enum MapTargetKind { Hex, Vertex, Edge }
public struct MapTarget
{
public MapTargetKind kind;
public HexCoord hex;
public VertexCoord vertex;
public EdgeCoord edge;
public Vector3 worldPosition; // onde colocar o marcador de seleção
public static MapTarget ForHex(HexCoord h, HexLayout layout) =>
new MapTarget { kind = MapTargetKind.Hex, hex = h, worldPosition = layout.HexToWorld(h) };
public static MapTarget ForVertex(VertexCoord v, HexCoord h, HexLayout layout) =>
new MapTarget { kind = MapTargetKind.Vertex, vertex = v, hex = h, worldPosition = layout.VertexToWorld(v) };
public static MapTarget ForEdge(EdgeCoord e, HexCoord h, HexLayout layout) =>
new MapTarget { kind = MapTargetKind.Edge, edge = e, hex = h, worldPosition = layout.EdgeToWorld(e) };
public override string ToString() => kind switch
{
MapTargetKind.Vertex => vertex.ToString(),
MapTargetKind.Edge => edge.ToString(),
_ => hex.ToString(),
};
}
}
3.5 Arrastar, dar zoom e tocar: MapPointer
// Caminho: Assets/_Project/Scripts/View/MapPointer.cs
using System;
using System.Collections.Generic;
using ProjetoHex.Grid;
using ProjetoHex.Meta;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
namespace ProjetoHex.View
{
// POR QUE: o mesmo gesto serve para duas coisas. Arrastar move a câmera;
// tocar e soltar sem arrastar seleciona algo no mapa. No PC isso vem do
// mouse, no celular do dedo.
// ESTRATÉGIA: Pointer.current (Input System) representa "o último mouse ou
// dedo usado", então um código só serve para as três plataformas. Se o dedo
// andar mais que _tapMaxPixels, é arrasto; senão, é toque. O toque é
// convertido em hexágono pela matemática da Fase 1 (sem Collider2D), e
// depois em "interseção" ou "aresta" se caiu perto de um canto ou de um lado.
public class MapPointer : MonoBehaviour
{
[SerializeField] private Camera _camera;
[SerializeField] private Transform _selectionMarker;
[SerializeField] private float _tapMaxPixels = 15f;
[SerializeField] private float _minZoom = 2f;
[SerializeField] private float _maxZoom = 12f;
[SerializeField] private float _scrollZoomStep = 0.8f;
[SerializeField] private float _pinchZoomSpeed = 0.02f;
[Tooltip("Raio, em fração do tamanho do hexágono, para o toque 'grudar' num canto ou num lado.")]
[SerializeField] private float _pickRadius = 0.3f;
[SerializeField] private bool _logTaps = true;
// Avisado a cada toque válido no mapa (o ActionPanel da Fase 5 ouve isto).
public event Action<MapTarget> Tapped;
private bool _pressing;
private bool _dragging;
private Vector2 _pressScreenPos;
private Vector3 _dragOriginWorld;
private float _lastPinchDistance = -1f;
private static readonly List<RaycastResult> UiHits = new List<RaycastResult>();
// Começa sem nada selecionado.
private void Start()
{
if (_selectionMarker != null) _selectionMarker.gameObject.SetActive(false);
}
// Lê o input a cada frame: zoom, pinça, arrasto e toque.
private void Update()
{
HandleScrollZoom();
if (HandlePinch())
{
_pressing = false; // durante a pinça, nada de arrasto nem toque
return;
}
Pointer pointer = Pointer.current;
if (pointer == null) return;
Vector2 screenPos = pointer.position.ReadValue();
if (pointer.press.wasPressedThisFrame && !IsOverUI(screenPos))
{
_pressing = true;
_dragging = false;
_pressScreenPos = screenPos;
_dragOriginWorld = ScreenToWorld(screenPos);
}
if (_pressing && pointer.press.isPressed)
{
if (!_dragging && Vector2.Distance(screenPos, _pressScreenPos) > _tapMaxPixels)
_dragging = true;
if (_dragging)
{
// Move a câmera para que o ponto agarrado continue embaixo do dedo.
_camera.transform.position += _dragOriginWorld - ScreenToWorld(screenPos);
ClampCamera();
}
}
if (_pressing && pointer.press.wasReleasedThisFrame)
{
_pressing = false;
if (!_dragging) HandleTap(screenPos);
}
}
// Converte o toque em alvo, move o marcador e avisa quem estiver ouvindo.
private void HandleTap(Vector2 screenPos)
{
MapTarget target = Pick(ScreenToWorld(screenPos));
if (!GameManager.Instance.World.InMap(target.hex))
{
if (_selectionMarker != null) _selectionMarker.gameObject.SetActive(false);
return;
}
if (_selectionMarker != null)
{
_selectionMarker.position = target.worldPosition;
_selectionMarker.gameObject.SetActive(true);
}
if (_logTaps) Debug.Log($"Toque em {target}");
Tapped?.Invoke(target);
}
// Hexágono sob o ponto; se estiver perto de um canto, é interseção; se
// estiver perto do meio de um lado, é aresta. Cantos têm prioridade.
private MapTarget Pick(Vector3 world)
{
HexLayout layout = GameManager.Instance.Layout;
HexCoord hex = layout.WorldToHex(world);
VertexCoord[] corners = hex.Corners();
float radius = layout.size * _pickRadius;
int best = -1;
float bestDist = float.MaxValue;
for (int i = 0; i < 6; i++)
{
float d = Vector2.Distance(world, layout.VertexToWorld(corners[i]));
if (d < bestDist) { bestDist = d; best = i; }
}
if (bestDist <= radius) return MapTarget.ForVertex(corners[best], hex, layout);
best = -1;
bestDist = float.MaxValue;
for (int i = 0; i < 6; i++)
{
Vector3 mid = (layout.VertexToWorld(corners[i]) + layout.VertexToWorld(corners[(i + 1) % 6])) * 0.5f;
float d = Vector2.Distance(world, mid);
if (d < bestDist) { bestDist = d; best = i; }
}
if (bestDist <= radius)
return MapTarget.ForEdge(new EdgeCoord(corners[best], corners[(best + 1) % 6]), hex, layout);
return MapTarget.ForHex(hex, layout);
}
// Roda do mouse: um "degrau" de zoom por clique. Usa só o sinal porque o
// valor bruto da roda muda de plataforma para plataforma.
private void HandleScrollZoom()
{
Mouse mouse = Mouse.current;
if (mouse == null) return;
float scroll = mouse.scroll.ReadValue().y;
if (Mathf.Approximately(scroll, 0f) || IsOverUI(mouse.position.ReadValue())) return;
Zoom(-Mathf.Sign(scroll) * _scrollZoomStep);
}
// Dois dedos na tela: afastar = zoom in, aproximar = zoom out.
// Devolve true enquanto a pinça está acontecendo.
private bool HandlePinch()
{
Touchscreen screen = Touchscreen.current;
if (screen == null) return false;
var t0 = screen.touches[0];
var t1 = screen.touches[1];
if (!t0.isInProgress || !t1.isInProgress)
{
_lastPinchDistance = -1f;
return false;
}
float distance = Vector2.Distance(t0.position.ReadValue(), t1.position.ReadValue());
if (_lastPinchDistance > 0f) Zoom((_lastPinchDistance - distance) * _pinchZoomSpeed);
_lastPinchDistance = distance;
return true;
}
// Muda o tamanho da câmera ortográfica (maior = vê mais mapa).
private void Zoom(float delta)
{
_camera.orthographicSize = Mathf.Clamp(_camera.orthographicSize + delta, _minZoom, _maxZoom);
}
// Não deixa a câmera fugir do mapa.
private void ClampCamera()
{
HexLayout layout = GameManager.Instance.Layout;
int radius = GameManager.Instance.Config.mapRadius;
float maxX = layout.size * 1.7320508f * (radius + 0.5f);
float maxY = layout.size * 1.5f * radius * layout.yScale + layout.size;
Vector3 p = _camera.transform.position;
p.x = Mathf.Clamp(p.x, -maxX, maxX);
p.y = Mathf.Clamp(p.y, -maxY, maxY);
_camera.transform.position = p;
}
// Posição da tela (pixels) para posição do mundo (unidades), no plano z = 0.
private Vector3 ScreenToWorld(Vector2 screen)
{
Vector3 p = _camera.ScreenToWorldPoint(new Vector3(screen.x, screen.y, 0f));
p.z = 0f;
return p;
}
// O ponto está em cima de um botão/painel? Então o toque é da UI, não do mapa.
// Faz um raycast na UI em vez de usar IsPointerOverGameObject(), que é
// pouco confiável com toque no Input System.
private static bool IsOverUI(Vector2 screenPos)
{
if (EventSystem.current == null) return false;
var data = new PointerEventData(EventSystem.current) { position = screenPos };
UiHits.Clear();
EventSystem.current.RaycastAll(data, UiHits);
return UiHits.Count > 0;
}
}
}
3.6 Montar a cena
- Sprites placeholder (em
Assets/_Project/Sprites/): use Create → 2D → Sprites → Hexagon Point-Top, Circle e Square. - Prefab
HexTile(emPrefabs/):-
Crie um GameObject vazio
HexTilecom o componenteHexTileView. -
Filho
Terrain: SpriteRenderer com o sprite Hexagon Point-Top, Sorting LayerTerreno, Order 0. -
Filho
Fog: o mesmo sprite, Sorting LayerTerreno, Order 1. -
Ajuste a Scale de
TerraineFog(o mesmo valor nos dois) até um hexágono dehexSize = 1ter cerca de 1,73 unidade de largura e 2 de altura. Depois multiplique o Y da escala peloisoYScale(0.6). O ajuste fino é visual: na Fase 3 os hexágonos vizinhos devem encostar sem frestas. -
Filho
Progress(é o_progressRoot), posicionado um pouco abaixo do centro (ex.: y = -0.35):- neto
Bg: Square escuro com escala (0.8, 0.08), Sorting LayerMarcadores; - neto
FillPivot(é o_progressFill): vazio em x = -0.4, a ponta esquerda da barra; - bisneto
Fill, filho deFillPivot: Square claro em x = 0.4, escala (0.8, 0.08), Sorting LayerMarcadores, Order 1.
Escalar o
FillPivotem X faz a barra crescer a partir da esquerda. - neto
-
Filho
ScoutMarker: Circle amarelo pequeno (escala 0.3), Sorting LayerMarcadores. -
Arraste os filhos para os campos do
HexTileView.
-
- Prefab
Building: GameObject com SpriteRenderer (Circle, escala 0.35), Sorting LayerConstrucoes. Arte final Em aberto (Parte 1 §12). - Prefab
Road:- Crie um GameObject com LineRenderer.
- Em Materials, use Sprites-Default.
- Em Additional Settings, coloque Sorting Layer =
Estradas.
- Na cena
Game:- GameObject
GameManagercom o componenteGameManager. - GameObject
MapcomMapView. Arraste os 3 prefabs;Village SpriteeCity Spritepodem ficar vazios (usa o Circle do prefab). - Main Camera: Projection Orthographic, Size 6, Background azul-escuro. Adicione
MapPointerna própria câmera e arraste a câmera para o campoCamera. - Marcador de seleção: Circle branco semitransparente (escala 0.5) chamado
Selection, Sorting LayerMarcadores, Order 5, arrastado paraSelection Marker. - GameObject → UI → Event System, se ainda não existir. Clique em Replace with InputSystemUIInputModule se o Unity sugerir.
- GameObject
✅ Checkpoint da Fase 3
- Ao apertar Play, aparece um mapa em forma de hexágono, quase todo na névoa escura. No centro há cerca de 10 hexágonos coloridos revelados e um círculo azul (sua vila) numa interseção.
- Os 3 hexágonos em volta da vila têm cores diferentes e mostram a barrinha de progresso (ainda parada, porque o relógio vem na Fase 4).
- Os hexágonos na névoa colados nos revelados aparecem num tom mais claro (fronteira).
- Arrastar com o mouse ou com o dedo move o mapa, e a roda ou a pinça dão zoom.
- Clicar perto de um canto loga
Toque em Vertex(...), perto do meio de um lado logaEdge(...)e no meio do hexágono logaHex(...). O marcador branco vai para o lugar certo. - O
HexGridSmokeTestda Fase 1 pode ser apagado.
Problemas comuns
- Hexágonos com frestas ou sobrepostos: a escala dos filhos
Terrain/Fognão bate comhexSize. Ajuste no prefab até encostarem; o código não precisa mudar. - A vila aparece atrás dos hexágonos: o prefab
Buildingficou na Sorting LayerDefaultouTerreno. UseConstrucoes. - Toque não funciona no celular, mas funciona no PC: falta o Event System, ou ele usa o módulo antigo (
StandaloneInputModule). Troque porInputSystemUIInputModule.
Próxima fase: Coleta por Tempo, com o relógio do jogo, os hexágonos produzindo e a barra de recursos.
Fase 4 — Coleta por Tempo
Objetivo desta fase: o tempo passa. Cada hexágono fecha ciclos, as construções que o tocam recebem recursos (Parte 1 §5) e a barra de recursos mostra o estoque subindo.
4.1 O relógio do mundo: Simulation
// Caminho: Assets/_Project/Scripts/Sim/Simulation.cs
using ProjetoHex.Grid;
namespace ProjetoHex.Sim
{
// POR QUE: "tudo é por tempo" (Parte 1 §5). Algo precisa avançar os relógios
// de produção de cada hexágono e das missões dos batedores.
// ESTRATÉGIA: uma função pura Tick(world, dt) que avança o mundo dt
// segundos. Ela não sabe se o tempo é "ao vivo" (Update) ou "offline" (vários
// Ticks seguidos ao abrir o jogo, Fase 7), e por isso serve para os dois.
// Cada hexágono tem um relógio só; quando o ciclo fecha, todas as
// construções que o tocam recebem (o "dado do Catan" trocado por tempo).
public static class Simulation
{
// Avança o mundo em dt segundos.
public static void Tick(World w, float dt)
{
if (dt <= 0f) return;
foreach (HexCell cell in w.hexList)
{
cell.progress += dt;
float cycle = cell.terrain.cycleSeconds;
// "while" e não "if": com dt grande (offline), podem fechar vários ciclos de uma vez.
while (cell.progress >= cycle)
{
cell.progress -= cycle;
Produce(w, cell);
}
}
// Missões de batedor (usadas a partir da Fase 5).
bool changed = false;
foreach (Faction f in w.factions)
{
for (int i = f.scoutJobs.Count - 1; i >= 0; i--) // de trás para frente, porque remove da lista
{
ScoutJob job = f.scoutJobs[i];
job.remaining -= dt;
if (job.remaining <= 0f)
{
f.revealed.Add(job.target);
f.scoutJobs.RemoveAt(i);
changed = true;
}
}
}
if (changed) w.NotifyChanged();
}
// Um ciclo fechou: cada vila/cidade num dos 6 cantos do hexágono recebe o recurso.
private static void Produce(World w, HexCell cell)
{
foreach (VertexCoord v in cell.coord.Corners())
{
if (!w.buildings.TryGetValue(v, out Building b)) continue;
int yield = b.level == Building.City ? w.config.cityYield : w.config.villageYield;
w.factions[b.owner].Add(cell.terrain.resource, yield * cell.terrain.amountPerCycle);
}
}
}
}
4.2 Ligar o relógio no GameManager
Acrescente na classe GameManager da Fase 3 o campo e os dois métodos abaixo:
[Tooltip("Só para testar: 10 = o tempo do jogo passa 10x mais rápido.")]
[SerializeField] private float _debugTimeScale = 1f;
// Update roda uma vez por frame. Time.deltaTime = segundos desde o frame anterior.
private void Update()
{
Step(Time.deltaTime * _debugTimeScale);
}
// Avança o jogo em dt segundos (a Fase 6 acrescenta o rival aqui).
private void Step(float dt)
{
Simulation.Tick(World, dt);
}
4.3 Barra de recursos: ResourceBar
// Caminho: Assets/_Project/Scripts/UI/ResourceBar.cs
using ProjetoHex.Data;
using ProjetoHex.Meta;
using ProjetoHex.Sim;
using TMPro;
using UnityEngine;
namespace ProjetoHex.UI
{
// POR QUE: o jogador precisa ver o estoque subindo. É o retorno principal do
// loop de coleta (Parte 1 §13).
// ESTRATÉGIA: lê o estoque do World a cada frame e escreve nos textos. O
// estoque muda a todo ciclo, então ler sempre é mais simples do que um
// evento por recurso.
public class ResourceBar : MonoBehaviour
{
[Tooltip("Um texto por recurso, na ordem do enum: Minério, Trigo, Lã, Argila, Peixe.")]
[SerializeField] private TMP_Text[] _resourceTexts = new TMP_Text[ResourceTypes.Count];
[SerializeField] private TMP_Text _scoutsText;
// Atualiza os textos com o estoque atual do jogador.
private void Update()
{
Faction player = GameManager.Instance.World.factions[World.Player];
for (int i = 0; i < _resourceTexts.Length && i < ResourceTypes.Count; i++)
if (_resourceTexts[i] != null)
_resourceTexts[i].text = $"{ResourceTypes.Label((ResourceType)i)}: {player.bank[i]}";
if (_scoutsText != null)
_scoutsText.text = $"Batedores: {player.IdleScouts}/{player.scoutsOwned}";
}
}
}
4.4 Montar a UI
- GameObject → UI → Canvas. No Canvas Scaler, use UI Scale Mode = Scale With Screen Size, Reference Resolution = 1920 × 1080 e Match = 0.5. Isso funciona razoavelmente no PC, no celular e no navegador; a orientação final está Em aberto (Parte 1 §13).
- Dentro do Canvas, crie um painel
ResourceBarancorado no topo, com Horizontal Layout Group. - Dentro do painel, crie 6 textos UI → Text - TextMeshPro: 5 de recursos e 1 de batedores.
- Adicione
ResourceBarao painel e arraste os textos (na ordem do enum) e o de batedores.
✅ Checkpoint da Fase 4
- Ao dar Play, as barrinhas dos 3 hexágonos em volta da sua vila enchem e reiniciam, cada uma no seu ritmo.
- A cada barrinha cheia, o recurso daquele terreno sobe 1 na barra do topo.
- Com
Debug Time Scale = 10noGameManager, tudo acontece 10x mais rápido. - A barra mostra
Batedores: 1/1.
Problemas comuns
- Recursos não sobem: o
GameManagernão tem oUpdateda 4.2, ou ocycleSecondsdos terrenos está muito alto. Teste comDebug Time Scalemaior. - Acentos aparecem como quadrados: a fonte do TMP não tem o caractere. Use a fonte padrão LiberationSans SDF (que tem fallback dinâmico) ou uma fonte com Latin-1.
Próxima fase: Construir e Explorar, com as regras de vila, cidade, estrada e batedor e o painel de ação ao tocar no mapa.
Fase 5 — Construir e Explorar
Objetivo desta fase: tocar numa interseção e construir uma vila ou virar cidade; tocar numa aresta e construir uma estrada; tocar num hexágono na névoa e mandar um batedor; treinar batedores. Tudo checado pelas regras da Parte 1 §4 e §6.
5.1 Regras de construção: BuildRules
// Caminho: Assets/_Project/Scripts/Sim/BuildRules.cs
using ProjetoHex.Grid;
namespace ProjetoHex.Sim
{
// POR QUE: decidir se uma vila, cidade ou estrada pode ser construída, e
// explicar por que não quando não pode. A mesma regra serve para o
// jogador (painel de ação) e para o rival (Fase 6).
// ESTRATÉGIA: funções que só leem o World e devolvem true/false com um
// motivo em texto ("out string reason"). As regras Em aberto (Parte 1 §4)
// são lidas do GameConfig (distanceRule, requireRoadConnection).
public static class BuildRules
{
// A interseção serve para uma vila, sem contar estrada e custo? (o rival
// usa isto para escolher para onde crescer).
public static bool SiteIsFree(World w, int owner, VertexCoord v, out string reason)
{
if (!w.VertexInMap(v)) { reason = "Fora do mapa."; return false; }
if (w.buildings.ContainsKey(v)) { reason = "Já tem uma construção aqui."; return false; }
foreach (HexCoord h in v.TouchingHexes())
if (w.InMap(h) && !w.factions[owner].revealed.Contains(h))
{
reason = "Explore os hexágonos em volta primeiro.";
return false;
}
if (w.config.distanceRule)
foreach (VertexCoord n in v.AdjacentVertices())
if (w.buildings.ContainsKey(n)) { reason = "Muito perto de outra construção."; return false; }
reason = null;
return true;
}
// Pode construir uma vila aqui agora?
public static bool CanBuildVillage(World w, int owner, VertexCoord v, out string reason)
{
if (!SiteIsFree(w, owner, v, out reason)) return false;
if (w.config.requireRoadConnection && !HasOwnRoadAt(w, owner, v))
{
reason = "Precisa de uma estrada sua chegando aqui.";
return false;
}
if (!w.factions[owner].CanAfford(w.config.villageCost)) { reason = "Recursos insuficientes."; return false; }
reason = null;
return true;
}
// Pode transformar esta vila em cidade?
public static bool CanUpgradeToCity(World w, int owner, VertexCoord v, out string reason)
{
if (!w.buildings.TryGetValue(v, out Building b) || b.owner != owner) { reason = "Não é uma vila sua."; return false; }
if (b.level != Building.Village) { reason = "Já é uma cidade."; return false; }
if (!w.factions[owner].CanAfford(w.config.cityCost)) { reason = "Recursos insuficientes."; return false; }
reason = null;
return true;
}
// Pode construir uma estrada nesta aresta?
public static bool CanBuildRoad(World w, int owner, EdgeCoord e, out string reason)
{
if (w.roads.ContainsKey(e)) { reason = "Já tem estrada aqui."; return false; }
if (!TouchesRevealed(w, owner, e)) { reason = "Explore os hexágonos deste lado primeiro."; return false; }
if (!EndpointConnects(w, owner, e.a, e) && !EndpointConnects(w, owner, e.b, e))
{
reason = "A estrada precisa sair de uma vila ou estrada sua.";
return false;
}
if (!w.factions[owner].CanAfford(w.config.roadCost)) { reason = "Recursos insuficientes."; return false; }
reason = null;
return true;
}
// Pelo menos um dos 2 hexágonos da aresta já foi explorado por este jogador?
public static bool TouchesRevealed(World w, int owner, EdgeCoord e)
{
foreach (HexCoord h in e.TouchingHexes())
if (w.factions[owner].revealed.Contains(h)) return true;
return false;
}
// Alguma estrada deste jogador chega nesta interseção?
private static bool HasOwnRoadAt(World w, int owner, VertexCoord v)
{
foreach (VertexCoord n in v.AdjacentVertices())
if (w.roads.TryGetValue(new EdgeCoord(v, n), out Road road) && road.owner == owner)
return true;
return false;
}
// A estrada nova "encosta" na rede deste jogador por esta ponta? Como no
// Catan, uma construção do rival na ponta bloqueia a passagem.
private static bool EndpointConnects(World w, int owner, VertexCoord v, EdgeCoord except)
{
if (w.buildings.TryGetValue(v, out Building b)) return b.owner == owner;
foreach (VertexCoord n in v.AdjacentVertices())
{
var e = new EdgeCoord(v, n);
if (e != except && w.roads.TryGetValue(e, out Road road) && road.owner == owner)
return true;
}
return false;
}
}
}
5.2 Regras do batedor: ScoutRules
// Caminho: Assets/_Project/Scripts/Sim/ScoutRules.cs
using ProjetoHex.Grid;
namespace ProjetoHex.Sim
{
// POR QUE: decidir para onde um batedor pode ir e se dá para treinar outro
// (Parte 1 §6, placeholders de MVP).
// ESTRATÉGIA: mesmo formato do BuildRules (true/false + motivo).
public static class ScoutRules
{
// Pode mandar um batedor para este hexágono?
public static bool CanSendScout(World w, int owner, HexCoord target, out string reason)
{
Faction f = w.factions[owner];
if (!w.InMap(target)) { reason = "Fora do mapa."; return false; }
if (f.revealed.Contains(target)) { reason = "Já explorado."; return false; }
if (f.IsScouting(target)) { reason = "Um batedor já está a caminho."; return false; }
if (!w.IsFrontier(owner, target)) { reason = "Só dá para explorar ao lado de um hexágono conhecido."; return false; }
if (f.IdleScouts <= 0) { reason = "Nenhum batedor livre."; return false; }
reason = null;
return true;
}
// Pode treinar mais um batedor?
public static bool CanTrainScout(World w, int owner, out string reason)
{
Faction f = w.factions[owner];
if (f.scoutsOwned >= w.config.maxScouts) { reason = $"Limite de {w.config.maxScouts} batedores."; return false; }
if (!f.CanAfford(w.config.scoutCost)) { reason = "Recursos insuficientes."; return false; }
reason = null;
return true;
}
}
}
5.3 As ações: WorldActions
// Caminho: Assets/_Project/Scripts/Sim/WorldActions.cs
using ProjetoHex.Grid;
namespace ProjetoHex.Sim
{
// POR QUE: o único jeito de jogador e rival mudarem o mundo. Checa a regra,
// paga o custo, aplica a mudança e avisa a tela, sempre nessa ordem.
// ESTRATÉGIA: cada Try* chama a regra correspondente primeiro; se ela disser
// não, nada muda e o motivo volta para quem chamou mostrar na tela.
public static class WorldActions
{
// Constrói uma vila na interseção.
public static bool TryBuildVillage(World w, int owner, VertexCoord v, out string reason)
{
if (!BuildRules.CanBuildVillage(w, owner, v, out reason)) return false;
w.factions[owner].Pay(w.config.villageCost);
w.buildings[v] = new Building { vertex = v, owner = owner, level = Building.Village };
w.NotifyChanged();
return true;
}
// Transforma uma vila em cidade.
public static bool TryUpgradeToCity(World w, int owner, VertexCoord v, out string reason)
{
if (!BuildRules.CanUpgradeToCity(w, owner, v, out reason)) return false;
w.factions[owner].Pay(w.config.cityCost);
w.buildings[v].level = Building.City;
w.NotifyChanged();
return true;
}
// Constrói uma estrada na aresta.
public static bool TryBuildRoad(World w, int owner, EdgeCoord e, out string reason)
{
if (!BuildRules.CanBuildRoad(w, owner, e, out reason)) return false;
w.factions[owner].Pay(w.config.roadCost);
w.roads[e] = new Road { edge = e, owner = owner };
w.NotifyChanged();
return true;
}
// Manda um batedor livre para o hexágono; ele revela quando o tempo acabar (Simulation).
public static bool TrySendScout(World w, int owner, HexCoord target, out string reason)
{
if (!ScoutRules.CanSendScout(w, owner, target, out reason)) return false;
w.factions[owner].scoutJobs.Add(new ScoutJob { target = target, remaining = w.config.scoutSeconds });
w.NotifyChanged();
return true;
}
// Treina mais um batedor (instantâneo no MVP, Parte 1 §7).
public static bool TryTrainScout(World w, int owner, out string reason)
{
if (!ScoutRules.CanTrainScout(w, owner, out reason)) return false;
w.factions[owner].Pay(w.config.scoutCost);
w.factions[owner].scoutsOwned++;
w.NotifyChanged();
return true;
}
}
}
5.4 O painel de ação: ActionPanel
// Caminho: Assets/_Project/Scripts/UI/ActionPanel.cs
using System.Text;
using ProjetoHex.Data;
using ProjetoHex.Grid;
using ProjetoHex.Meta;
using ProjetoHex.Sim;
using ProjetoHex.View;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ProjetoHex.UI
{
// POR QUE: ao tocar no mapa, o jogador vê o que é aquilo e qual ação dá
// para fazer ali (Parte 1 §13). Se a ação não for possível, o painel mostra
// o motivo vindo das regras ("Precisa de uma estrada sua chegando aqui").
// ESTRATÉGIA: um botão de ação só, que muda de função conforme o alvo:
// interseção vazia = vila, sua vila = cidade, aresta = estrada, névoa =
// batedor. Enquanto está aberto, o painel se atualiza a cada frame, porque
// o estoque sobe sozinho e o botão pode passar a ser possível.
public class ActionPanel : MonoBehaviour
{
[SerializeField] private MapPointer _pointer;
[Tooltip("O painel visível. Precisa ser um FILHO deste objeto: este script tem de ficar ativo para ouvir os toques.")]
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text _title;
[SerializeField] private TMP_Text _details;
[SerializeField] private Button _actionButton;
[SerializeField] private TMP_Text _actionLabel;
[SerializeField] private Button _closeButton;
private enum PanelAction { None, BuildVillage, UpgradeCity, BuildRoad, SendScout }
private MapTarget _target;
private PanelAction _action;
private bool _open;
// Liga os botões uma vez e começa fechado.
private void Awake()
{
_actionButton.onClick.AddListener(OnActionClicked);
_closeButton.onClick.AddListener(Hide);
Hide();
}
// OnEnable/OnDisable: começa/para de ouvir os toques no mapa.
private void OnEnable() => _pointer.Tapped += Show;
private void OnDisable() => _pointer.Tapped -= Show;
// Abre o painel para o alvo tocado.
private void Show(MapTarget target)
{
_target = target;
_open = true;
_root.SetActive(true);
RefreshPanel();
}
// Fecha o painel.
private void Hide()
{
_open = false;
_root.SetActive(false);
}
// Mantém o painel em dia enquanto está aberto.
private void Update()
{
if (_open) RefreshPanel();
}
// Decide título, detalhes e botão conforme o tipo de alvo e o estado do mundo.
private void RefreshPanel()
{
World w = GameManager.Instance.World;
Faction me = w.factions[World.Player];
_action = PanelAction.None;
bool can = false;
string reason = null;
switch (_target.kind)
{
case MapTargetKind.Vertex:
if (w.buildings.TryGetValue(_target.vertex, out Building b))
{
bool mine = b.owner == World.Player;
_title.text = !mine ? "Construção do rival" : b.level == Building.City ? "Sua cidade" : "Sua vila";
if (mine && b.level == Building.Village)
{
_action = PanelAction.UpgradeCity;
can = BuildRules.CanUpgradeToCity(w, World.Player, _target.vertex, out reason);
_actionLabel.text = $"Virar cidade ({ResourceTypes.Format(w.config.cityCost)})";
}
}
else
{
_title.text = "Interseção livre";
_action = PanelAction.BuildVillage;
can = BuildRules.CanBuildVillage(w, World.Player, _target.vertex, out reason);
_actionLabel.text = $"Construir vila ({ResourceTypes.Format(w.config.villageCost)})";
}
_details.text = DescribeHexes(w, _target.vertex.TouchingHexes(), me);
break;
case MapTargetKind.Edge:
if (w.roads.TryGetValue(_target.edge, out Road road))
{
_title.text = road.owner == World.Player ? "Sua estrada" : "Estrada do rival";
}
else
{
_title.text = "Caminho livre";
_action = PanelAction.BuildRoad;
can = BuildRules.CanBuildRoad(w, World.Player, _target.edge, out reason);
_actionLabel.text = $"Construir estrada ({ResourceTypes.Format(w.config.roadCost)})";
}
_details.text = "Estradas levam suas vilas a novas interseções.";
break;
default: // Hex
if (me.revealed.Contains(_target.hex) && w.hexes.TryGetValue(_target.hex, out HexCell cell))
{
_title.text = cell.terrain.displayName;
_details.text = $"Produz {cell.terrain.amountPerCycle} {ResourceTypes.Label(cell.terrain.resource)} " +
$"a cada {cell.terrain.cycleSeconds:0}s para cada vila encostada (cidade: {w.config.cityYield}x).";
}
else
{
_title.text = "Terra desconhecida";
_details.text = ScoutStatus(me, _target.hex, w.config.scoutSeconds);
_action = PanelAction.SendScout;
can = ScoutRules.CanSendScout(w, World.Player, _target.hex, out reason);
_actionLabel.text = $"Enviar batedor ({w.config.scoutSeconds:0}s)";
}
break;
}
_actionButton.gameObject.SetActive(_action != PanelAction.None);
_actionButton.interactable = can;
if (_action != PanelAction.None && !can && reason != null)
_details.text += $"\n<color=#ff8080>{reason}</color>";
}
// Executa a ação do botão. As regras já foram checadas, mas WorldActions checa de novo.
private void OnActionClicked()
{
World w = GameManager.Instance.World;
switch (_action)
{
case PanelAction.BuildVillage: WorldActions.TryBuildVillage(w, World.Player, _target.vertex, out _); break;
case PanelAction.UpgradeCity: WorldActions.TryUpgradeToCity(w, World.Player, _target.vertex, out _); break;
case PanelAction.BuildRoad: WorldActions.TryBuildRoad(w, World.Player, _target.edge, out _); break;
case PanelAction.SendScout: WorldActions.TrySendScout(w, World.Player, _target.hex, out _); break;
}
RefreshPanel();
}
// Lista o que uma interseção coletaria: um item por hexágono que ela toca.
private static string DescribeHexes(World w, HexCoord[] hexes, Faction me)
{
var sb = new StringBuilder("Coleta de:");
foreach (HexCoord h in hexes)
{
if (!w.hexes.TryGetValue(h, out HexCell cell)) continue;
if (!me.revealed.Contains(h)) { sb.Append("\n- ? (inexplorado)"); continue; }
sb.Append($"\n- {ResourceTypes.Label(cell.terrain.resource)} a cada {cell.terrain.cycleSeconds:0}s");
}
return sb.ToString();
}
// Texto de um hexágono na névoa: tem batedor a caminho?
private static string ScoutStatus(Faction me, HexCoord hex, float scoutSeconds)
{
foreach (ScoutJob job in me.scoutJobs)
if (job.target == hex) return $"Batedor a caminho: faltam {job.remaining:0}s.";
return $"Um batedor leva {scoutSeconds:0}s para revelar este hexágono.";
}
}
}
5.5 Botão de treinar batedor: TrainScoutButton
// Caminho: Assets/_Project/Scripts/UI/TrainScoutButton.cs
using ProjetoHex.Data;
using ProjetoHex.Meta;
using ProjetoHex.Sim;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ProjetoHex.UI
{
// POR QUE: treinar batedores é uma ação global, não de um lugar do mapa, então
// fica num botão fixo da HUD (Parte 1 §13).
// ESTRATÉGIA: o botão só fica clicável quando ScoutRules permite; o texto mostra o custo.
public class TrainScoutButton : MonoBehaviour
{
[SerializeField] private Button _button;
[SerializeField] private TMP_Text _label;
// Liga o clique à ação.
private void Awake()
{
_button.onClick.AddListener(() => WorldActions.TryTrainScout(GameManager.Instance.World, World.Player, out _));
}
// Atualiza se dá para clicar e o texto do custo.
private void Update()
{
World w = GameManager.Instance.World;
_button.interactable = ScoutRules.CanTrainScout(w, World.Player, out _);
_label.text = $"Treinar batedor ({ResourceTypes.Format(w.config.scoutCost)})";
}
}
}
5.6 Montar a UI
- No Canvas, crie um GameObject vazio
ActionPanelcom o scriptActionPanel. - Dentro dele, crie o filho
Panel(UI → Panel), ancorado embaixo, com:- um texto
Titlee um textoDetails; - um botão
ActionButton, com o textoLabel; - um botão
Close("X").
- um texto
- Arraste para os campos do
ActionPanel:Panelvai emRoot(é o filho que liga e desliga; oActionPanelem si fica sempre ativo);- a câmera (que tem o
MapPointer) vai emPointer; - os textos e botões vão nos campos correspondentes.
- Crie um botão
TrainScoutno canto da HUD, com o scriptTrainScoutButton. Arraste o próprioButtone o texto dele para os campos.
✅ Checkpoint da Fase 5
- Tocar numa aresta colada na sua vila mostra "Caminho livre" e o botão "Construir estrada". Depois de construir, a linha azul aparece e o estoque cai.
- Tocar na interseção do outro lado da estrada mostra "Construir vila". Se você estiver sem recursos ou a área não estiver explorada, o motivo aparece em vermelho e o botão fica cinza.
- Tocar numa interseção colada numa construção mostra "Muito perto de outra construção" (com
distanceRuleligada). - Tocar num hexágono de névoa clara e "Enviar batedor" faz aparecer o marcador amarelo. Depois de 60 s (ou menos, com
Debug Time Scale), o hexágono é revelado e a fronteira avança. - Tocar na sua vila e "Virar cidade" deixa o círculo maior, e os hexágonos em volta passam a render o dobro.
- Desligar
requireRoadConnectionnoGameConfigpermite construir vila sem estrada.
Problemas comuns
- O painel abre uma vez e nunca mais: o script
ActionPanelestá no mesmo objeto que oRoot. Ao esconder, oOnDisablepara de ouvir os toques. Coloque o painel visível como filho. - Clicar no botão também seleciona o mapa atrás dele: o painel precisa de uma Image com Raycast Target marcado no fundo, para o
IsOverUIdoMapPointerdetectar a UI. - Nunca consigo tocar numa interseção (sempre dá hexágono): o
Pick RadiusdoMapPointerficou pequeno para o zoom atual. Aumente para 0.35 ou dê mais zoom.
Próxima fase: Rival IA, com uma vila controlada pelo computador que explora, constrói e disputa interseções com você.
Fase 6 — Rival IA
Objetivo desta fase: o rival do MVP (Parte 1 §9). Ele explora com batedores, abre estradas e ocupa as melhores interseções antes de você. Comércio e conflito ficam para depois.
6.1 O cérebro do rival: RivalAI
// Caminho: Assets/_Project/Scripts/Sim/RivalAI.cs
using System.Collections.Generic;
using ProjetoHex.Grid;
using UnityEngine;
namespace ProjetoHex.Sim
{
// POR QUE: o MVP tem 1 rival que só disputa espaço (Parte 1 §9 e §16). Ele
// precisa parecer que "quer" as boas interseções, sem ser perfeito.
// ESTRATÉGIA: a cada rivalThinkSeconds, o rival decide UMA ação de
// construção, em ordem de prioridade:
// 1) virar cidade, se já tem os recursos;
// 2) construir vila no melhor lugar que já alcança (se o lugar existe mas
// falta recurso, ele ESPERA juntando, em vez de gastar em estrada);
// 3) abrir estrada rumo ao melhor lugar a até 3 arestas de distância
// (busca em largura, BFS);
// e, à parte, cuida dos batedores. Ele usa as mesmas WorldActions do
// jogador, então nunca quebra as regras. É C# puro, então também roda no
// progresso offline.
public class RivalAI
{
private const int MaxRoadSearch = 3; // quantas arestas à frente ele procura lugares
private enum SiteResult { Built, Waiting, None }
private readonly int _owner;
private readonly System.Random _rng = new System.Random();
public RivalAI(int owner)
{
_owner = owner;
}
// Chamado pelo GameManager com o tempo que passou; decide quando der a hora.
public void Tick(World w, float dt)
{
w.rivalThinkTimer += dt;
while (w.rivalThinkTimer >= w.config.rivalThinkSeconds)
{
w.rivalThinkTimer -= w.config.rivalThinkSeconds;
Think(w);
}
}
// Uma rodada de decisão.
private void Think(World w)
{
ManageScouts(w);
if (TryUpgradeACity(w)) return;
SiteResult site = TryBestVillage(w);
if (site != SiteResult.None) return; // construiu ou está juntando recurso para isso
TryRoadTowardBestSite(w);
}
// 1) Vira cidade a vila que coleta mais, se tiver os recursos.
private bool TryUpgradeACity(World w)
{
if (!w.factions[_owner].CanAfford(w.config.cityCost)) return false;
Building best = null;
float bestScore = -1f;
foreach (Building b in w.buildings.Values)
{
if (b.owner != _owner || b.level != Building.Village) continue;
float s = SiteScore(w, b.vertex);
if (s > bestScore) { bestScore = s; best = b; }
}
return best != null && WorldActions.TryUpgradeToCity(w, _owner, best.vertex, out _);
}
// 2) Vila no melhor lugar já alcançado pelas estradas.
private SiteResult TryBestVillage(World w)
{
IEnumerable<VertexCoord> candidates = w.config.requireRoadConnection ? NetworkVertices(w) : RevealedCorners(w);
VertexCoord best = default;
float bestScore = -1f;
foreach (VertexCoord v in candidates)
{
if (!BuildRules.SiteIsFree(w, _owner, v, out _)) continue;
float s = SiteScore(w, v);
if (s > bestScore) { bestScore = s; best = v; }
}
if (bestScore < 0f) return SiteResult.None;
return WorldActions.TryBuildVillage(w, _owner, best, out _) ? SiteResult.Built : SiteResult.Waiting;
}
// 3) Busca em largura a partir da rede de estradas até achar o melhor
// lugar a até MaxRoadSearch arestas; constrói o PRIMEIRO trecho do caminho.
private void TryRoadTowardBestSite(World w)
{
if (!w.config.requireRoadConnection) return;
var depth = new Dictionary<VertexCoord, int>();
var parent = new Dictionary<VertexCoord, VertexCoord>();
var queue = new Queue<VertexCoord>();
foreach (VertexCoord v in NetworkVertices(w))
{
if (OccupiedByOther(w, v)) continue;
depth[v] = 0;
queue.Enqueue(v);
}
VertexCoord best = default;
float bestScore = -1f;
while (queue.Count > 0)
{
VertexCoord v = queue.Dequeue();
int d = depth[v];
if (d > 0 && BuildRules.SiteIsFree(w, _owner, v, out _))
{
float s = SiteScore(w, v) - d * 0.01f; // no empate, o mais perto ganha
if (s > bestScore) { bestScore = s; best = v; }
}
if (d >= MaxRoadSearch) continue;
foreach (VertexCoord n in v.AdjacentVertices())
{
if (depth.ContainsKey(n) || !w.VertexInMap(n) || OccupiedByOther(w, n)) continue;
var e = new EdgeCoord(v, n);
if (w.roads.ContainsKey(e) || !BuildRules.TouchesRevealed(w, _owner, e)) continue;
depth[n] = d + 1;
parent[n] = v;
queue.Enqueue(n);
}
}
if (bestScore < 0f) return;
// Volta pelo caminho até o passo que sai da rede.
VertexCoord step = best;
while (depth[parent[step]] > 0) step = parent[step];
WorldActions.TryBuildRoad(w, _owner, new EdgeCoord(parent[step], step), out _);
}
// Treina até 2 batedores e manda os livres para a fronteira, de preferência perto de casa.
private void ManageScouts(World w)
{
Faction f = w.factions[_owner];
if (f.scoutsOwned < 2) WorldActions.TryTrainScout(w, _owner, out _);
if (f.IdleScouts <= 0) return;
HexCoord home = (_owner == World.Rival ? w.rivalStart : w.playerStart).Hex;
var frontier = new List<HexCoord>();
foreach (HexCoord h in f.revealed)
for (int d = 0; d < 6; d++)
{
HexCoord n = h.Neighbor(d);
if (w.IsFrontier(_owner, n) && !f.IsScouting(n) && !frontier.Contains(n)) frontier.Add(n);
}
frontier.Sort((a, b) => a.DistanceTo(home).CompareTo(b.DistanceTo(home)));
while (f.IdleScouts > 0 && frontier.Count > 0)
{
int i = _rng.Next(Mathf.Min(3, frontier.Count)); // um dos 3 mais perto, para não ser previsível
WorldActions.TrySendScout(w, _owner, frontier[i], out _);
frontier.RemoveAt(i);
}
}
// Quanto vale uma interseção: recurso por segundo dos hexágonos revelados
// que ela toca, com bônus para o que está faltando no estoque.
private float SiteScore(World w, VertexCoord v)
{
Faction f = w.factions[_owner];
float score = 0f;
foreach (HexCoord h in v.TouchingHexes())
{
if (!w.hexes.TryGetValue(h, out HexCell cell) || !f.revealed.Contains(h)) continue;
float perSecond = cell.terrain.amountPerCycle / cell.terrain.cycleSeconds;
bool lacking = f.Get(cell.terrain.resource) < 2;
score += perSecond * (lacking ? 1.5f : 1f);
}
return score;
}
// Todas as interseções onde o rival já está: suas construções e as pontas das suas estradas.
private HashSet<VertexCoord> NetworkVertices(World w)
{
var set = new HashSet<VertexCoord>();
foreach (Building b in w.buildings.Values)
if (b.owner == _owner) set.Add(b.vertex);
foreach (Road r in w.roads.Values)
if (r.owner == _owner) { set.Add(r.edge.a); set.Add(r.edge.b); }
return set;
}
// Sem a regra da estrada: qualquer canto de hexágono revelado é candidato.
private HashSet<VertexCoord> RevealedCorners(World w)
{
var set = new HashSet<VertexCoord>();
foreach (HexCoord h in w.factions[_owner].revealed)
foreach (VertexCoord v in h.Corners()) set.Add(v);
return set;
}
// Tem construção de outro jogador nesta interseção?
private bool OccupiedByOther(World w, VertexCoord v) =>
w.buildings.TryGetValue(v, out Building b) && b.owner != _owner;
}
}
6.2 Ligar o rival no GameManager
No GameManager:
- Acrescente o campo
private RivalAI _rival;. - No fim do
Awake, acrescente_rival = new RivalAI(World.Rival);. - Troque o
Steppor:
// Avança o jogo em dt segundos: produção, batedores e o rival.
private void Step(float dt)
{
Simulation.Tick(World, dt);
if (Config.rivalEnabled) _rival.Tick(World, dt);
}
✅ Checkpoint da Fase 6
- Com
Debug Time Scale = 10, depois de alguns minutos de jogo, mandar batedores na direção do rival revela estradas e vilas vermelhas. - Tocar numa interseção vermelha mostra "Construção do rival", sem botão de ação.
- O rival não constrói colado nas suas construções (regra de distância) nem passa estrada por cima da sua vila.
- Com
rivalEnableddesligado noGameConfige um jogo novo, não aparece rival nenhum.
Problemas comuns
- O rival não faz nada: com
rivalEnableddesligado na hora em que o jogo foi criado, ele não ganhou vila inicial. Ligue e comece um jogo novo (na Fase 7, apague o save). - O rival trava sem crescer: a vila inicial dele está encostada em hexágonos que ele não consegue explorar (borda do mapa). Teste outra seed ou aumente
mapRadius. - Quero ver o rival sem explorar: para debug, troque temporariamente
PlayerSeesnoMapViewpara sempre retornartrue.
Próxima fase: Save Local e Progresso Offline, para fechar o jogo, voltar horas depois e encontrar tudo produzido.
Fase 7 — Save Local e Progresso Offline
Objetivo desta fase: o jogo salva sozinho no aparelho (Parte 1 §15). Ao reabrir, ou ao voltar de outra aba ou app, ele calcula o tempo que passou (até o limite de 8 h) e simula esse tempo de uma vez, mostrando "enquanto você esteve fora" (Parte 1 §10).
7.1 O formato do save: SaveData
// Caminho: Assets/_Project/Scripts/Meta/SaveData.cs
using System;
using System.Collections.Generic;
using ProjetoHex.Grid;
using ProjetoHex.Sim;
namespace ProjetoHex.Meta
{
// POR QUE: o World usa Dictionary e HashSet, que o JsonUtility do Unity não
// sabe gravar. Este é o "retrato" do mundo em listas e arrays simples.
// ESTRATÉGIA: o terreno NÃO é gravado: guarda-se a seed e o mapa é gerado de
// novo (WorldFactory.Generate é determinístico). mapRadius e terrainCount
// servem para detectar um save de uma configuração diferente.
[Serializable]
public class SaveData
{
public int version;
public int seed;
public int mapRadius;
public int terrainCount;
public long savedAtUtcTicks; // DateTime.UtcNow.Ticks do momento do save
public float[] hexProgress; // na ordem de World.hexList
public FactionSave[] factions;
public List<Building> buildings;
public List<Road> roads;
public float rivalThinkTimer;
}
// POR QUE: a parte de um jogador no save (estoque, névoa, batedores).
[Serializable]
public class FactionSave
{
public int[] bank;
public List<HexCoord> revealed;
public int scoutsOwned;
public List<ScoutJob> scoutJobs;
}
// POR QUE: o que mostrar na tela "enquanto você esteve fora".
public class OfflineReport
{
public double seconds;
public bool hitCap;
public int[] gained;
public int hexesRevealed;
}
}
7.2 Gravar e carregar: SaveSystem
// Caminho: Assets/_Project/Scripts/Meta/SaveSystem.cs
using System;
using System.Collections.Generic;
using ProjetoHex.Data;
using ProjetoHex.Grid;
using ProjetoHex.Sim;
using UnityEngine;
namespace ProjetoHex.Meta
{
// POR QUE: o MVP é offline com save local (Parte 1 §15) e precisa funcionar
// igual em PC, celular e navegador.
// ESTRATÉGIA: o mundo vira JSON (JsonUtility) e é guardado no PlayerPrefs.
// PlayerPrefs funciona nas três plataformas sem código especial (no WebGL
// ele vai para o armazenamento do navegador). O save tem poucos KB, bem
// abaixo do limite de 1 MB do WebGL.
public static class SaveSystem
{
private const string Key = "projetohex_save";
private const int CurrentVersion = 1;
// Grava o mundo inteiro, com a hora atual.
public static void Save(World w)
{
var data = new SaveData
{
version = CurrentVersion,
seed = w.seed,
mapRadius = w.config.mapRadius,
terrainCount = w.config.terrains.Length,
savedAtUtcTicks = DateTime.UtcNow.Ticks,
hexProgress = new float[w.hexList.Count],
factions = new FactionSave[w.factions.Length],
buildings = new List<Building>(w.buildings.Values),
roads = new List<Road>(w.roads.Values),
rivalThinkTimer = w.rivalThinkTimer,
};
for (int i = 0; i < w.hexList.Count; i++) data.hexProgress[i] = w.hexList[i].progress;
for (int i = 0; i < w.factions.Length; i++)
{
Faction f = w.factions[i];
data.factions[i] = new FactionSave
{
bank = (int[])f.bank.Clone(),
revealed = new List<HexCoord>(f.revealed),
scoutsOwned = f.scoutsOwned,
scoutJobs = new List<ScoutJob>(f.scoutJobs),
};
}
PlayerPrefs.SetString(Key, JsonUtility.ToJson(data));
PlayerPrefs.Save(); // grava no disco agora, não só ao fechar o jogo
}
// Tenta remontar o mundo do save. Devolve false se não há save ou se ele
// é de outra versão/configuração (aí o GameManager começa um jogo novo).
public static bool TryLoad(GameConfig config, out World world, out long savedAtUtcTicks)
{
world = null;
savedAtUtcTicks = 0;
if (!PlayerPrefs.HasKey(Key)) return false;
SaveData data;
try
{
data = JsonUtility.FromJson<SaveData>(PlayerPrefs.GetString(Key));
}
catch (Exception e)
{
Debug.LogWarning($"Save corrompido, começando do zero: {e.Message}");
return false;
}
if (data == null || data.version != CurrentVersion ||
data.mapRadius != config.mapRadius || data.terrainCount != config.terrains.Length)
{
Debug.LogWarning("Save de outra versão ou configuração; começando do zero.");
return false;
}
World w = WorldFactory.Generate(config, data.seed);
if (data.hexProgress == null || data.hexProgress.Length != w.hexList.Count) return false;
for (int i = 0; i < w.hexList.Count; i++) w.hexList[i].progress = data.hexProgress[i];
for (int i = 0; i < w.factions.Length && i < data.factions.Length; i++)
{
Faction f = w.factions[i];
FactionSave s = data.factions[i];
Array.Copy(s.bank, f.bank, Math.Min(s.bank.Length, f.bank.Length));
foreach (HexCoord h in s.revealed) f.revealed.Add(h);
f.scoutsOwned = s.scoutsOwned;
f.scoutJobs.AddRange(s.scoutJobs);
}
foreach (Building b in data.buildings) w.buildings[b.vertex] = b;
foreach (Road r in data.roads) w.roads[r.edge] = r;
w.rivalThinkTimer = data.rivalThinkTimer;
world = w;
savedAtUtcTicks = data.savedAtUtcTicks;
return true;
}
// Apaga o save (para testar do zero).
public static void Delete()
{
PlayerPrefs.DeleteKey(Key);
PlayerPrefs.Save();
}
}
}
7.3 GameManager (versão completa)
Substitua o GameManager inteiro por esta versão, que junta o relógio da Fase 4, o rival da Fase 6, o save e o progresso offline:
// Caminho: Assets/_Project/Scripts/Meta/GameManager.cs (versão completa)
using System;
using ProjetoHex.Data;
using ProjetoHex.Grid;
using ProjetoHex.Sim;
using UnityEngine;
namespace ProjetoHex.Meta
{
// POR QUE: dono do World e do relógio. Cria ou carrega o mundo, faz o tempo
// passar, salva sozinho e aplica o tempo em que o jogador esteve fora
// (Parte 1 §10).
// ESTRATÉGIA:
// - Tempo ao vivo: Step(Time.deltaTime) a cada frame.
// - Tempo fora: a diferença do relógio do aparelho (DateTime.UtcNow), aplicada
// em passos de 1 s com o mesmo Step. O Update compara a hora real entre
// frames: se passou mais de alguns segundos (celular bloqueado, aba do
// navegador escondida, PC hibernando), também conta como tempo fora. O
// Unity não roda Update nesses casos, e Time.deltaTime não "vê" a pausa.
// - Save: sempre que algo estrutural muda (evento Changed), a cada
// autosaveSeconds e ao pausar/fechar.
[DefaultExecutionOrder(-100)]
public class GameManager : MonoBehaviour
{
private const float OfflineStepSeconds = 1f;
[Tooltip("Só para testar: 10 = o tempo do jogo passa 10x mais rápido.")]
[SerializeField] private float _debugTimeScale = 1f;
[Tooltip("Pausas maiores que isso (aba escondida, celular bloqueado) contam como tempo offline.")]
[SerializeField] private float _gapAsOfflineSeconds = 5f;
public static GameManager Instance { get; private set; }
public GameConfig Config { get; private set; }
public HexLayout Layout { get; private set; }
public World World { get; private set; }
// Avisado quando um tempo offline é aplicado (a tela de "enquanto você esteve fora" ouve).
public event Action<OfflineReport> OfflineProgressApplied;
private RivalAI _rival;
private OfflineReport _pendingReport;
private DateTime _lastFrameUtc;
private float _autosaveTimer;
private bool _dirty;
private bool _saveDisabled;
// Carrega o save (e aplica o tempo fora) ou cria um jogo novo.
private void Awake()
{
Instance = this;
Config = GameConfig.Load();
Layout = new HexLayout(Config.hexSize, Config.isoYScale);
_rival = new RivalAI(World.Rival);
if (SaveSystem.TryLoad(Config, out World loaded, out long savedAtTicks))
{
World = loaded;
double away = (DateTime.UtcNow - new DateTime(savedAtTicks, DateTimeKind.Utc)).TotalSeconds;
ApplyOffline(away);
}
else
{
World = WorldFactory.NewGame(Config);
}
World.Changed += () => _dirty = true;
_lastFrameUtc = DateTime.UtcNow;
}
// A cada frame: avança o tempo (ao vivo ou "pulo" offline) e salva se precisar.
private void Update()
{
DateTime now = DateTime.UtcNow;
double gap = (now - _lastFrameUtc).TotalSeconds;
_lastFrameUtc = now;
if (gap > _gapAsOfflineSeconds) ApplyOffline(gap);
else Step(Time.deltaTime * _debugTimeScale);
_autosaveTimer += Time.unscaledDeltaTime;
if (_dirty || _autosaveTimer >= Config.autosaveSeconds) Save();
}
// Celular indo para segundo plano: salva já, porque o sistema pode matar o app.
private void OnApplicationPause(bool paused)
{
if (paused) Save();
}
// Fechando o jogo (no WebGL nem sempre é chamado; por isso existe o autosave).
private void OnApplicationQuit() => Save();
// Grava agora.
public void Save()
{
if (_saveDisabled) return;
SaveSystem.Save(World);
_dirty = false;
_autosaveTimer = 0f;
}
// A tela de offline pega o relatório gerado no Awake (antes de ela existir).
public OfflineReport ConsumePendingReport()
{
OfflineReport r = _pendingReport;
_pendingReport = null;
return r;
}
// Avança o jogo em dt segundos: produção, batedores e o rival.
private void Step(float dt)
{
Simulation.Tick(World, dt);
if (Config.rivalEnabled) _rival.Tick(World, dt);
}
// Simula o tempo fora (até o limite) e monta o relatório do que o jogador ganhou.
private void ApplyOffline(double seconds)
{
if (seconds <= 0) return; // relógio do aparelho voltou para trás: ignora (Parte 1 §10)
double cap = Config.offlineCapHours * 3600.0;
double applied = Math.Min(seconds, cap);
Faction player = World.factions[World.Player];
var before = (int[])player.bank.Clone();
int revealedBefore = player.revealed.Count;
double left = applied;
while (left > 0)
{
float dt = (float)Math.Min(OfflineStepSeconds, left);
Step(dt);
left -= dt;
}
var report = new OfflineReport
{
seconds = applied,
hitCap = seconds > cap,
gained = new int[ResourceTypes.Count],
hexesRevealed = player.revealed.Count - revealedBefore,
};
for (int i = 0; i < ResourceTypes.Count; i++) report.gained[i] = player.bank[i] - before[i];
_pendingReport = report;
OfflineProgressApplied?.Invoke(report);
}
// Menu de contexto do componente (clique nos 3 pontinhos do GameManager no Inspector).
[ContextMenu("Apagar save")]
private void DeleteSave()
{
SaveSystem.Delete();
_saveDisabled = Application.isPlaying; // se estiver jogando, não regrava ao sair do Play
Debug.Log("Save apagado. O próximo Play começa um jogo novo.");
}
}
}
7.4 Tela "enquanto você esteve fora": OfflineReportPanel
// Caminho: Assets/_Project/Scripts/UI/OfflineReportPanel.cs
using System;
using System.Text;
using ProjetoHex.Data;
using ProjetoHex.Meta;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace ProjetoHex.UI
{
// POR QUE: no idle, voltar e ver "olha quanto você ganhou" é a recompensa
// principal (Parte 1 §10). Sem essa tela, o progresso offline passa despercebido.
// ESTRATÉGIA: pega o relatório do Awake (abriu o jogo) com
// ConsumePendingReport, e ouve o evento para voltas de aba ou app com o jogo
// já aberto. Ausências curtas (< 1 min) não abrem a tela.
public class OfflineReportPanel : MonoBehaviour
{
[Tooltip("O painel visível, filho deste objeto.")]
[SerializeField] private GameObject _root;
[SerializeField] private TMP_Text _text;
[SerializeField] private Button _okButton;
[SerializeField] private float _minSecondsToShow = 60f;
// Liga o botão e começa escondido.
private void Awake()
{
_okButton.onClick.AddListener(() => _root.SetActive(false));
_root.SetActive(false);
}
// Mostra o relatório da abertura do jogo, se houver, e passa a ouvir os próximos.
private void Start()
{
GameManager gm = GameManager.Instance;
gm.OfflineProgressApplied += Show;
OfflineReport pending = gm.ConsumePendingReport();
if (pending != null) Show(pending);
}
// Para de ouvir quando a cena fecha.
private void OnDestroy()
{
if (GameManager.Instance != null) GameManager.Instance.OfflineProgressApplied -= Show;
}
// Monta o texto e abre o painel.
private void Show(OfflineReport r)
{
if (r.seconds < _minSecondsToShow) return;
TimeSpan t = TimeSpan.FromSeconds(r.seconds);
var sb = new StringBuilder();
sb.AppendLine($"Você ficou fora por {(int)t.TotalHours}h {t.Minutes:00}min.");
if (r.hitCap) sb.AppendLine("(A produção offline tem um limite. Volte antes para não perder nada.)");
bool any = false;
for (int i = 0; i < r.gained.Length; i++)
if (r.gained[i] > 0)
{
sb.AppendLine($"+{r.gained[i]} {ResourceTypes.Label((ResourceType)i)}");
any = true;
}
if (r.hexesRevealed > 0) sb.AppendLine($"Seus batedores revelaram {r.hexesRevealed} hexágono(s).");
if (!any) sb.AppendLine("Nada foi produzido. Construa vilas encostadas em recursos.");
_text.text = sb.ToString();
_root.SetActive(true);
}
}
}
7.5 Montar a UI
- No Canvas, crie um GameObject vazio
OfflineReportcom o scriptOfflineReportPanel. - Dentro dele, crie o filho
Panel(UI → Panel), centralizado, com um texto e um botão "OK". - Arraste o
PanelparaRoot, e o texto e o botão para os campos correspondentes. - Deixe o
OfflineReportcomo o último filho do Canvas, para ele ficar por cima de tudo.
✅ Checkpoint da Fase 7
- Construa uma estrada, saia do Play e entre de novo: a estrada continua lá, com o mesmo mapa e o mesmo estoque.
- Saia do Play, espere 2 minutos e entre de novo: aparece "Você ficou fora por 0h 02min" com os recursos ganhos, e os batedores em missão terminaram se o tempo deu.
- Com o jogo rodando, minimize o editor ou troque de aba no WebGL por mais de 1 minuto. Ao voltar, aparece a mesma tela.
- Apagar save (menu de contexto do
GameManager) faz o próximo Play começar um mapa novo.
Problemas comuns
- Toda vez começa um jogo novo: o log "Save de outra versão ou configuração" indica que o
mapRadiusou a quantidade de terrenos mudou desde o save. É de propósito; apague o save. Se não aparece log nenhum, oSave()não está rodando: confira se oWorld.Changed += ...está noAwake. - Voltar depois de 8 h trava o jogo por um instante: são 28.800 passos de 1 s simulados de uma vez. Se ficar lento num celular fraco, aumente
OfflineStepSecondspara 5. A produção continua certa, porqueSimulation.Tickusawhilepara os ciclos. - Adiantar o relógio do aparelho dá recursos de graça: é esperado sem backend (Parte 1 §10, Em aberto). O limite de 8 h só reduz o estrago.
Próxima fase: Build e Próximos Passos, com builds para PC, Android/iOS e WebGL e o que vem depois do MVP.
Fase 8 — Build e Próximos Passos
Objetivo desta fase: o MVP rodando fora do editor nas três plataformas (Parte 1 §1), e a lista do que vem depois.
8.1 Antes de qualquer build
- No
GameConfig, deixe Fixed Seed = 0 (mapa aleatório) e confira se oDebug Time ScaledoGameManagerestá em 1. - Desligue o
Log TapsdoMapPointer. - Em Project Settings → Player, preencha Company Name e Product Name. Use o nome de trabalho até o nome definitivo sair (Em aberto).
- Cuidado: o PlayerPrefs de PC e de celular é guardado por Company/Product Name. Trocar o nome depois "perde" o save dos testadores.
8.2 PC (Windows / Steam / Itch)
- File → Build Profiles → Windows → Build.
- Teste:
- arrastar e dar zoom com o mouse;
- fechar pela janela e reabrir (o save precisa estar lá).
8.3 Mobile (Android / iOS)
- File → Build Profiles → Android (ou iOS) → Switch Platform.
- Player → Resolution and Presentation → Default Orientation: a orientação está Em aberto (Parte 1 §13). Para o MVP, use Landscape Left, que combina com a referência 1920 × 1080 do Canvas.
- Teste num aparelho real:
- pinça para zoom;
- toque em interseção (pode precisar de mais zoom ou de um
Pick Radiusmaior em telas pequenas); - bloquear a tela por 2 minutos e voltar (a tela de offline precisa aparecer).
8.4 Web (WebGL)
- File → Build Profiles → Web → Switch Platform.
- Player → Publishing Settings: para o itch.io, marque Decompression Fallback, porque o servidor do itch nem sempre manda os cabeçalhos de compressão que o Unity espera.
- Build, compacte a pasta gerada num
.zipe suba no itch.io como jogo HTML. - Teste:
- recarregar a página (o save fica no navegador; o modo anônimo apaga tudo ao fechar);
- trocar de aba por mais de 1 minuto e voltar.
Problemas comuns
- WebGL abre com tela preta e erro de "Content-Encoding": é o servidor sem os cabeçalhos de compressão. Marque Decompression Fallback, ou use Compression Format = Disabled.
- No celular, tocar em botões também mexe no mapa: algum painel não tem Image com Raycast Target, então o
IsOverUInão o enxerga.
✅ Checkpoint da Fase 8
- Os builds de PC, de celular e de WebGL abrem, mostram o mapa e aceitam input.
- Nas três plataformas, fechar e reabrir mantém o progresso e mostra a tela de offline quando passou mais de 1 minuto.
8.5 Próximos passos (pós-MVP)
Em ordem sugerida. Tudo depende das decisões Em aberto da Parte 1 §18:
- Decidir a motivação de longo prazo (Parte 1 §11), antes de mais conteúdo: é o maior risco do design.
- Arte isométrica:
- fechar o estilo (Parte 1 §12);
- gerar os 5 hexágonos, a vila e a cidade. O PixelLab tem geração de tiles isométricos, e o SpriteCook gera arte HD;
- ajustar
isoYScaleà altura da arte; - preencher o campo
spritede cadaTerrainDef.
- Mais terrenos: principalmente madeira (Parte 1 §3). É um
ResourceTypenovo no fim do enum (atualizeResourceTypes.Count) e umTerrainDefnovo. - Comércio com o rival (Parte 1 §9): uma
WorldActions.TryTradecom taxas noGameConfig. - Soldados e conflito (Parte 1 §7 e §9): o tipo de unidade e as regras de ataque ainda precisam ser desenhados.
- Melhorias/tecnologia (Parte 1 §8): multiplicadores no cálculo de
Simulation.Producee nocycleSeconds. - Mais rivais: o
World.factionsjá é um array, e oRivalAIrecebe o dono no construtor. - Save na nuvem e checagem de horário pelo servidor (Parte 1 §15), se a fraude de relógio virar problema.
Versão: rascunho inicial — a refinar conforme prototipagem.