Mercador & Legião — Tutorial Unity (MVP)
Versão: 0.1.0 · histórico
Projeto: Mercador & Legião | Nível: Iniciante
Este documento continua o tutorial do mapa e fecha todo o loop do MVP: Heróis → Quests → Combate → Loot → Crafting → Loja. A ordem dos passos importa — construímos de dentro pra fora (dados → lógica pura → gerenciadores → UI), pra você sempre conseguir testar cada pedaço isoladamente antes de conectar o resto.
0. Ordem de construção (siga essa sequência)
- ScriptableObjects (dados) —
HeroData,ItemData,MonsterData,RecipeData CombatSimulator(lógica pura, testável sem UI)SaveSystem(persistência em JSON)QuestManager(liga timer real + combate + relatório)CraftingManager(Ferreiro)EconomyManager(loja/NPC cliente)- UI de cada painel (Heróis, Quests, Forja, Loja)
Cada seção abaixo segue essa ordem.
1. ScriptableObjects — os "dados" do jogo
1.1 HeroData.cs
using UnityEngine;
[CreateAssetMenu(fileName = "NewHero", menuName = "Jogo/Heroi")]
public class HeroData : ScriptableObject
{
public string heroName;
public HeroClass heroClass; // enum: Guerreiro, Ladino, Mago
public Sprite portrait;
[Header("Atributos primários base")]
public int baseStrength;
public int baseAgility;
public int baseIntelligence;
}
public enum HeroClass { Guerreiro, Ladino, Mago }
Isso é o molde. O herói de verdade, em jogo (com nível, vida atual, XP, itens equipados), é uma classe C# comum (não ScriptableObject, porque ScriptableObject é pra dados fixos — o estado de um herói muda o tempo todo):
[System.Serializable]
public class HeroInstance
{
public string instanceId; // ID único (pra salvar/carregar)
public HeroData baseData; // Referência ao molde
public int level = 1;
public int currentXP = 0;
public int currentHP;
public ItemInstance[] equippedItems = new ItemInstance[5]; // Cabeça, Peito, MaoPrincipal, OffHand, Acessorio
// Atributos secundários calculados (não salvos, recalculados sempre que precisar)
public int GetPhysicalAttack()
{
int baseAtk = baseData.heroClass == HeroClass.Mago ? 0 : baseData.baseStrength * 2;
int itemBonus = 0;
foreach (var item in equippedItems)
if (item != null) itemBonus += item.physicalAttackBonus;
return baseAtk + itemBonus + (level * 2);
}
public int GetDefense()
{
int itemBonus = 0;
foreach (var item in equippedItems)
if (item != null) itemBonus += item.defenseBonus;
return itemBonus + (level);
}
public float GetCritChance()
{
float baseCrit = baseData.baseAgility * 0.01f;
float itemBonus = 0;
foreach (var item in equippedItems)
if (item != null) itemBonus += item.critBonus;
return Mathf.Clamp01(baseCrit + itemBonus);
}
}
Por que separar HeroData (molde) de HeroInstance (herói real)? Porque você pode ter 5 guerreiros diferentes em jogo, todos vindos do mesmo HeroData "Guerreiro Base", mas cada um com nível e itens diferentes. Se você misturasse tudo num ScriptableObject só, todos os guerreiros comprados compartilhariam o mesmo nível — o que quebraria o jogo.
1.2 ItemData.cs (molde) + ItemInstance (item real)
using UnityEngine;
[CreateAssetMenu(fileName = "NewItem", menuName = "Jogo/Item")]
public class ItemData : ScriptableObject
{
public string itemName;
public EquipSlot slot; // enum: Cabeca, Peito, MaoPrincipal, OffHand, Acessorio
public Sprite icon;
public int basePhysicalAttack;
public int baseDefense;
public float baseCritBonus;
public int baseSellPrice;
}
public enum EquipSlot { Cabeca, Peito, MaoPrincipal, OffHand, Acessorio }
[System.Serializable]
public class ItemInstance
{
public string instanceId;
public ItemData baseData;
public string suffix; // Ex: "do Fogo", ou "" se não tiver
public int physicalAttackBonus;
public int defenseBonus;
public float critBonus;
public int rarity; // 0 = comum, 1 = incomum, 2 = raro
}
1.3 MonsterData.cs
using UnityEngine;
[CreateAssetMenu(fileName = "NewMonster", menuName = "Jogo/Monstro")]
public class MonsterData : ScriptableObject
{
public string monsterName;
public int hp;
public int physicalAttack;
public int defense;
public float critChance;
}
1.4 RecipeData.cs (pro Ferreiro)
using UnityEngine;
[CreateAssetMenu(fileName = "NewRecipe", menuName = "Jogo/Receita")]
public class RecipeData : ScriptableObject
{
public string recipeName;
public ItemData resultItem;
public MaterialCost[] costs;
public int requiredCraftsmanLevel;
[Range(0f, 1f)] public float suffixChance;
}
[System.Serializable]
public class MaterialCost
{
public string materialId; // simplificado: string em vez de outro ScriptableObject, pra reduzir escopo
public int amount;
}
Ação no Editor: crie as pastas Assets/Data/Herois, Assets/Data/Itens, Assets/Data/Monstros, Assets/Data/Receitas e popule pelo menos: 3 HeroData (Guerreiro, Ladino, Mago base), 5 ItemData, 3 MonsterData (da Mata), 5 RecipeData.
2. CombatSimulator — lógica pura, sem MonoBehaviour
Essa é a classe mais importante pra testar isolada, porque não depende de UI nem de cena — você pode chamar ela de um teste, de um botão de debug, de onde quiser.
using System.Collections.Generic;
using UnityEngine;
public static class CombatSimulator
{
public class CombatResult
{
public bool victory;
public int remainingHP;
public List<string> log = new List<string>();
}
public static CombatResult Simulate(HeroInstance hero, MonsterData monster)
{
var result = new CombatResult();
int heroHP = hero.currentHP;
int monsterHP = monster.hp;
for (int turn = 1; turn <= 10; turn++)
{
// Turno do herói
int dmgToMonster = CalculateDamage(hero.GetPhysicalAttack(), monster.defense, hero.GetCritChance());
monsterHP -= dmgToMonster;
result.log.Add($"Turno {turn}: Herói causa {dmgToMonster} de dano.");
if (monsterHP <= 0)
{
result.victory = true;
result.remainingHP = heroHP;
return result;
}
// Turno do monstro
int dmgToHero = CalculateDamage(monster.physicalAttack, hero.GetDefense(), monster.critChance);
heroHP -= dmgToHero;
result.log.Add($"Turno {turn}: Monstro causa {dmgToHero} de dano.");
if (heroHP <= 0)
{
result.victory = false;
result.remainingHP = 0;
return result;
}
}
// Acabaram os 10 turnos sem morte — decide por quem está com mais % de vida
float heroPercent = (float)heroHP / hero.currentHP;
float monsterPercent = (float)monsterHP / monster.hp;
result.victory = heroPercent >= monsterPercent;
result.remainingHP = Mathf.Max(heroHP, 0);
return result;
}
static int CalculateDamage(int attack, int defense, float critChance)
{
int baseDamage = Mathf.Max(attack - (defense / 2), 1); // mínimo 1 de dano
bool isCrit = Random.value < critChance;
if (isCrit) baseDamage = Mathf.RoundToInt(baseDamage * 1.5f);
return baseDamage;
}
}
Como testar isso ANTES de ter qualquer UI: crie um script temporário CombatTestRunner.cs, coloque num objeto vazio na cena, arraste um HeroData e um MonsterData, e no Start() chame CombatSimulator.Simulate(...) e dê Debug.Log no resultado. Isso é uma prática valiosa pra iniciante: testar lógica isolada antes de complicar com interface.
3. SaveSystem — persistência em JSON
using UnityEngine;
using System.IO;
public static class SaveSystem
{
static string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
public static void Save(GameState state)
{
string json = JsonUtility.ToJson(state, true);
File.WriteAllText(SavePath, json);
}
public static GameState Load()
{
if (!File.Exists(SavePath)) return new GameState();
string json = File.ReadAllText(SavePath);
return JsonUtility.FromJson<GameState>(json);
}
}
[System.Serializable]
public class GameState
{
public int gold = 100;
public List<HeroInstance> heroes = new List<HeroInstance>();
public List<ItemInstance> inventory = new List<ItemInstance>();
public List<ActiveQuest> activeQuests = new List<ActiveQuest>();
public Dictionary<string, int> craftsmanLevels = new Dictionary<string, int>(); // Nota: JsonUtility não serializa Dictionary — ver aviso abaixo
}
Aviso importante de iniciante: JsonUtility do Unity não serializa Dictionary diretamente. Pra craftsmanLevels, use uma lista de um struct simples (List<CraftsmanLevelEntry> com string id e int level) em vez de Dictionary, ou troque JsonUtility por Newtonsoft.Json (via Package Manager) que lida com dicionários. Pra manter o MVP simples, recomendo a lista.
4. ActiveQuest + QuestManager
using System;
using UnityEngine;
[System.Serializable]
public class ActiveQuest
{
public string heroInstanceId;
public string regionName;
public string startTimeIso; // DateTime salvo como string ISO 8601
public string endTimeIso;
}
using UnityEngine;
using System;
using System.Collections.Generic;
public class QuestManager : MonoBehaviour
{
public GameState state; // referência ao estado carregado (viria do SaveSystem no boot do jogo)
public void StartQuest(HeroInstance hero, RegionData region)
{
DateTime start = DateTime.UtcNow;
DateTime end = start.AddMinutes(region.questDurationMinutes);
var quest = new ActiveQuest
{
heroInstanceId = hero.instanceId,
regionName = region.regionName,
startTimeIso = start.ToString("o"),
endTimeIso = end.ToString("o")
};
state.activeQuests.Add(quest);
SaveSystem.Save(state);
}
// Chamado ao abrir o app ou periodicamente, pra resolver quests que já terminaram
public void CheckCompletedQuests(RegionData[] allRegions)
{
DateTime now = DateTime.UtcNow;
var completed = new List<ActiveQuest>();
foreach (var quest in state.activeQuests)
{
DateTime end = DateTime.Parse(quest.endTimeIso);
if (now >= end)
completed.Add(quest);
}
foreach (var quest in completed)
{
ResolveQuest(quest, allRegions);
state.activeQuests.Remove(quest);
}
if (completed.Count > 0) SaveSystem.Save(state);
}
void ResolveQuest(ActiveQuest quest, RegionData[] allRegions)
{
RegionData region = Array.Find(allRegions, r => r.regionName == quest.regionName);
HeroInstance hero = state.heroes.Find(h => h.instanceId == quest.heroInstanceId);
MonsterData monster = region.possibleMonsters[UnityEngine.Random.Range(0, region.possibleMonsters.Length)];
var result = CombatSimulator.Simulate(hero, monster);
hero.currentHP = result.remainingHP;
if (result.victory)
{
hero.currentXP += 20; // valor fixo simples pro MVP
RollLoot(region);
}
// Aqui você dispararia um evento pra UI mostrar o relatório
Debug.Log($"Quest resolvida: {(result.victory ? "Vitória" : "Derrota")} contra {monster.monsterName}");
}
void RollLoot(RegionData region)
{
foreach (var entry in region.lootTable)
{
if (UnityEngine.Random.value <= entry.dropChance)
{
var newItem = new ItemInstance { baseData = entry.item, instanceId = Guid.NewGuid().ToString() };
state.inventory.Add(newItem);
}
}
}
// Chamado pelo MapManager quando o jogador escolhe uma região
public void OpenHeroSelectionFor(RegionData region)
{
// Aqui você abriria o painel de UI de seleção de herói,
// passando `region` e a lista `state.heroes` disponíveis (sem quest ativa)
}
}
Ponto-chave de aprendizado: CheckCompletedQuests deve ser chamado no Start() do jogo (ao abrir o app) — é isso que faz o jogo "processar o tempo que passou offline", que é a mecânica central de um idle game.
5. CraftingManager — Ferreiro
using UnityEngine;
using System;
public class CraftingManager : MonoBehaviour
{
public GameState state;
public int ferreiroLevel = 1; // simplificado: sem Dictionary, só essa estação no MVP
public bool CanCraft(RecipeData recipe)
{
if (ferreiroLevel < recipe.requiredCraftsmanLevel) return false;
// checagem de materiais fica simplificada no MVP (ver nota abaixo)
return true;
}
public ItemInstance Craft(RecipeData recipe)
{
if (!CanCraft(recipe)) return null;
var newItem = new ItemInstance
{
instanceId = Guid.NewGuid().ToString(),
baseData = recipe.resultItem,
physicalAttackBonus = recipe.resultItem.basePhysicalAttack,
defenseBonus = recipe.resultItem.baseDefense,
critBonus = recipe.resultItem.baseCritBonus
};
if (UnityEngine.Random.value <= recipe.suffixChance)
{
ApplySuffix(newItem);
}
state.inventory.Add(newItem);
ferreiroLevel += 1; // sobe de nível a cada craft (regra simplificada do MVP)
SaveSystem.Save(state);
return newItem;
}
void ApplySuffix(ItemInstance item)
{
string[] suffixes = { "do Fogo", "dos Ventos", "da Sombra" };
item.suffix = suffixes[UnityEngine.Random.Range(0, suffixes.Length)];
item.physicalAttackBonus += 2;
item.rarity = 1;
}
}
Nota de escopo: a checagem real de materiais (consumir minérios do inventário) fica de fora do MVP mínimo — comece craftando "de graça" pra validar o loop, depois adicione o consumo de MaterialCost.
6. EconomyManager — Loja
using UnityEngine;
public class EconomyManager : MonoBehaviour
{
public GameState state;
public ItemData currentClientWants; // sorteado periodicamente
public int currentOfferPrice;
public void GenerateNewClient(ItemData[] possibleWants)
{
currentClientWants = possibleWants[Random.Range(0, possibleWants.Length)];
int variance = Random.Range(-20, 21); // ±20%
currentOfferPrice = currentClientWants.baseSellPrice + (currentClientWants.baseSellPrice * variance / 100);
}
public bool SellItem(ItemInstance item)
{
if (item.baseData != currentClientWants) return false;
state.inventory.Remove(item);
state.gold += currentOfferPrice;
SaveSystem.Save(state);
currentClientWants = null; // cliente satisfeito, vai embora
return true;
}
}
7. Conectando tudo na UI (visão geral)
Você já tem o MapManager do tutorial anterior. Agora:
- Painel de Heróis: lista
state.heroes, mostra nível/atributos/itens equipados, botão de equipar (troca item do slot). - Painel de Quests: ao clicar numa região no mapa, abre lista de heróis sem quest ativa → botão "Enviar" chama
questManager.StartQuest(hero, region). - Painel da Forja: lista
RecipeDatadisponíveis (filtradas porferreiroLevel), botão "Criar" chamacraftingManager.Craft(recipe). - Painel da Loja: mostra
currentClientWantsecurrentOfferPrice, lista o inventário do jogador com botão "Vender" em cada item que bate com o pedido.
Um GameManager central (singleton) deve segurar a referência única de GameState e ser passado (via Inspector ou FindObjectOfType) para os 4 managers acima, garantindo que todos leem/escrevem o mesmo estado.
8. Checklist de teste do MVP completo
- Criar um herói de teste direto no
GameState(via código, noStart()) pra não depender de contratação ainda. - Rodar
CombatSimulator.Simulateisolado e conferir os logs no Console. - Iniciar uma quest, fechar e reabrir o jogo (ou avançar o relógio do sistema) pra confirmar que
CheckCompletedQuestsresolve corretamente com o app "fechado". - Craftar um item e confirmar que ele aparece no inventário salvo.
- Vender um item pro cliente e confirmar que o ouro aumenta e o item some do inventário.
- Fechar e abrir o jogo depois de cada passo acima, confirmando que o
SaveSystemmanteve tudo.
Se todos os itens dessa checklist passarem, o loop core inteiro do MVP está funcional — o resto é polimento de UI e conteúdo (mais heróis, itens, regiões).
Histórico de versões
- 0.1.0 (2026-10-26): início do versionamento; marca o estado do tutorial neste dia.