Voltar para o Blog
Quest Log

Jogo Idle Clicker na Unity: Números Grandes e Progresso Offline

Tela de jogo idle com uma moeda dourada gigante no centro, números enormes subindo e uma lista de upgrades ao lado

Unity idle clicker com as partes difíceis: números grandes em K/M/B, custo de upgrade exponencial, renda passiva e progresso offline com DateTime.UtcNow.

Ferramenta:UnityC#

Jogo Idle Clicker na Unity: Números Grandes e Progresso Offline

Um unity idle clicker parece o jogo mais simples do mundo: clica, ganha moeda, compra upgrade. As partes que dão trabalho estão escondidas: números que passam de trilhões e precisam aparecer como "1,23 T", custo de upgrade que cresce exponencialmente, renda passiva por segundo e o progresso offline, calculado com DateTime.UtcNow quando o jogador volta depois de horas. Este guia resolve cada uma em C# para o Unity 6.3 LTS, com save em JSON que funciona no celular.

No fim você tem um idle jogável que ganha dinheiro com o app fechado e não quebra quando o jogador mexe no relógio do telefone.

O loop do idle clicker na Unity

O gênero gira em torno de um ciclo curto:

  1. O jogador clica e ganha moeda.
  2. Compra um upgrade que gera moeda sozinho (renda passiva).
  3. A renda passiva paga o próximo upgrade, cada vez mais caro.
  4. O jogador sai, volta horas depois e encontra o que acumulou.

O que mantém alguém jogando é a relação entre o passo 2 e o 3: o custo cresce mais rápido que a renda, então cada compra demora um pouco mais. Se demorar demais, o jogador desiste; se for rápido, o jogo acaba em uma tarde. Esse equilíbrio é um problema de economia de jogo e aparece em números neste post.

Estrutura do projeto: uma cena com Canvas (Scale With Screen Size, 1080x1920), um botão grande para clicar, um texto de moedas, um texto de renda por segundo e uma lista vertical de upgrades. Um IdleGame central guarda o estado; a UI só lê.

Clique e moeda principal

O estado inteiro do jogo cabe numa classe serializável. Isso facilita o save: o que está nessa classe é salvo, o que não está é recalculado.

Arquivo IdleState.cs:

using System;
using System.Collections.Generic;

[Serializable]
public class IdleState
{
    public double coins;
    public double totalEarned;
    public List<int> upgradeLevels = new List<int>();
    public long lastSaveUtcTicks;
}

Arquivo UpgradeDefinition.cs:

using UnityEngine;

[CreateAssetMenu(fileName = "Upgrade", menuName = "Idle/Upgrade")]
public class UpgradeDefinition : ScriptableObject
{
    [SerializeField] private string displayName = "Minerador";
    [SerializeField] private double baseCost = 15;
    [SerializeField] private double costGrowth = 1.15;
    [SerializeField] private double incomePerLevel = 0.5;

    public string DisplayName => displayName;
    public double IncomePerLevel => incomePerLevel;

    public double CostForLevel(int ownedLevel)
    {
        return baseCost * System.Math.Pow(costGrowth, ownedLevel);
    }
}

O clique soma um valor fixo mais uma fração da renda passiva, o que mantém o clique relevante quando a renda cresce:

public void Click()
{
    double gain = 1 + IncomePerSecond * 0.05;
    AddCoins(gain);
}

Esse método fica no IdleGame, mostrado completo mais abaixo.

Upgrades com custo exponencial

A fórmula do custo é custo = base x taxa ^ nível. Com base 15 e taxa 1,15, o primeiro minerador custa 15, o décimo custa cerca de 53 e o quinquagésimo passa de 14 mil. A renda de cada nível é linear (0,5 por segundo por nível). Custo exponencial contra renda linear é o que cria a curva de espera.

Taxas de partida que funcionam:

  • 1,07: crescimento lento, o jogador compra muitos níveis. Bom para upgrades baratos.
  • 1,15: o padrão do gênero. Cada nível custa 15% a mais.
  • 1,25 ou mais: upgrades raros e caros, que o jogador compra poucas vezes.

Comprar 10 de uma vez (o botão "x10") pede a soma de uma progressão geométrica, e não um laço: o custo de comprar n níveis a partir do nível k é base x taxa^k x (taxa^n - 1) / (taxa - 1). Com 1000 níveis, o laço custaria 1000 multiplicações; a fórmula, duas potências.

public static double BulkCost(double baseCost, double growth, int owned, int amount)
{
    return baseCost * System.Math.Pow(growth, owned) * (System.Math.Pow(growth, amount) - 1) / (growth - 1);
}

Renda passiva por segundo

A renda passiva é somada em Update multiplicando pela fração de segundo do frame. Não use um contador que soma 1 segundo de renda por vez com coroutine: se o jogo travar por 2 segundos, você perde a renda desse tempo.

Arquivo IdleGame.cs:

using System;
using System.Collections.Generic;
using UnityEngine;

public class IdleGame : MonoBehaviour
{
    [SerializeField] private List<UpgradeDefinition> upgrades = new List<UpgradeDefinition>();
    [SerializeField] private float autosaveInterval = 30f;
    [SerializeField] private double maxOfflineHours = 8;

    private IdleState state;
    private float nextAutosave;

    public double Coins => state.coins;
    public double IncomePerSecond { get; private set; }
    public double LastOfflineEarnings { get; private set; }
    public IReadOnlyList<UpgradeDefinition> Upgrades => upgrades;
    public event Action Changed;

    private void Awake()
    {
        state = SaveSystem.Load() ?? new IdleState();
        while (state.upgradeLevels.Count < upgrades.Count)
        {
            state.upgradeLevels.Add(0);
        }
        RecalculateIncome();
        ApplyOfflineProgress();
    }

    private void Update()
    {
        AddCoins(IncomePerSecond * Time.unscaledDeltaTime);

        if (Time.unscaledTime >= nextAutosave)
        {
            nextAutosave = Time.unscaledTime + autosaveInterval;
            Save();
        }
    }

    public void Click()
    {
        double gain = 1 + IncomePerSecond * 0.05;
        AddCoins(gain);
    }

    public int LevelOf(int index)
    {
        return state.upgradeLevels[index];
    }

    public double CostOf(int index)
    {
        return upgrades[index].CostForLevel(state.upgradeLevels[index]);
    }

    public bool TryBuy(int index)
    {
        double cost = CostOf(index);
        if (state.coins < cost) return false;

        state.coins -= cost;
        state.upgradeLevels[index]++;
        RecalculateIncome();
        Changed?.Invoke();
        return true;
    }

    private void AddCoins(double amount)
    {
        state.coins += amount;
        state.totalEarned += amount;
        Changed?.Invoke();
    }

    private void RecalculateIncome()
    {
        double total = 0;
        for (int i = 0; i < upgrades.Count; i++)
        {
            total += upgrades[i].IncomePerLevel * state.upgradeLevels[i];
        }
        IncomePerSecond = total;
    }

    private void ApplyOfflineProgress()
    {
        if (state.lastSaveUtcTicks <= 0) return;

        long nowTicks = DateTime.UtcNow.Ticks;
        double seconds = TimeSpan.FromTicks(nowTicks - state.lastSaveUtcTicks).TotalSeconds;
        if (seconds <= 0) return;

        double capped = Math.Min(seconds, maxOfflineHours * 3600.0);
        LastOfflineEarnings = IncomePerSecond * capped;
        AddCoins(LastOfflineEarnings);
    }

    public void Save()
    {
        state.lastSaveUtcTicks = DateTime.UtcNow.Ticks;
        SaveSystem.Save(state);
    }

    private void OnApplicationPause(bool paused)
    {
        if (paused) Save();
    }

    private void OnApplicationFocus(bool hasFocus)
    {
        if (!hasFocus) Save();
    }

    private void OnApplicationQuit()
    {
        Save();
    }
}

Time.unscaledDeltaTime em vez de Time.deltaTime mantém a renda correndo mesmo se você pausar o jogo com Time.timeScale = 0 para abrir um menu. No idle, o dinheiro não para.

O evento Changed dispara a cada frame por causa da renda. Para UI simples tudo bem; se a lista de upgrades crescer, atualize os textos no máximo 10 vezes por segundo.

Números grandes: formatar e quando double não basta

Ninguém quer ler 1234567890123 na tela. O formato do gênero é número com 3 dígitos significativos e sufixo: 1,23 T.

Arquivo BigNumberFormat.cs:

using System;
using System.Globalization;

public static class BigNumberFormat
{
    private static readonly string[] Suffixes = { "", "K", "M", "B", "T", "Qa", "Qi", "Sx", "Sp", "Oc", "No", "Dc" };
    private static readonly CultureInfo PtBr = new CultureInfo("pt-BR");

    public static string Format(double value)
    {
        if (double.IsNaN(value) || double.IsInfinity(value)) return "∞";
        if (value < 1000) return Math.Floor(value).ToString("0", PtBr);

        int group = (int)Math.Floor(Math.Log10(value) / 3);
        double scaled = value / Math.Pow(1000, group);

        string suffix = group < Suffixes.Length ? Suffixes[group] : LetterSuffix(group - Suffixes.Length);
        string number = scaled >= 100 ? scaled.ToString("0", PtBr) : scaled >= 10 ? scaled.ToString("0.0", PtBr) : scaled.ToString("0.00", PtBr);
        return number + " " + suffix;
    }

    private static string LetterSuffix(int index)
    {
        char first = (char)('a' + index / 26 % 26);
        char second = (char)('a' + index % 26);
        return first.ToString() + second;
    }
}

O Log10(value) / 3 diz quantos grupos de mil o número tem: 1.234.567 tem log10 6,09, então grupo 2, sufixo "M", e 1,23 M. Quando acabam os sufixos nomeados (decilhão, 10 elevado a 33), o formato passa para letras: aa, ab, ac, um padrão comum em idles longos. O CultureInfo("pt-BR") usa vírgula decimal, como o jogador brasileiro espera.

Quando o double não basta. O double chega a cerca de 1,8 x 10^308 e guarda de 15 a 17 dígitos significativos. Em idle, isso é quase sempre suficiente, porque você mostra 3 dígitos e o erro de arredondamento na 17ª casa não aparece. Os dois casos em que ele falha:

  1. O jogo passa de 10^308. Alguns idles com prestígio chegam lá. O double vira Infinity, e o IsInfinity do formatador é sua rede de segurança.
  2. Você precisa de inteiro exato com muitos dígitos, como somar 1 a 10^20 e ver a diferença. Com double, 10^20 + 1 é igual a 10^20.

Para esses casos, System.Numerics.BigInteger faz parte do .NET Standard 2.1, perfil de API padrão dos projetos Unity 6, e guarda inteiros de qualquer tamanho. O custo: é mais lento, aloca memória e não serializa com JsonUtility (salve como string com ToString() e leia com BigInteger.Parse). A alternativa que muitos idles usam é uma struct própria de mantissa e expoente (um double entre 1 e 10 e um long de expoente), rápida e sem limite prático. Comece com double; troque só se o design pedir.

Até aqui o idle tem clique, upgrades, renda e números legíveis. Os próximos passos são os que fazem um idle segurar o jogador por semanas: prestígio, balanceamento de longo prazo, animação de feedback, anúncio recompensado e publicação na loja. É a parte em que fazer sozinho costuma travar, porque cada decisão de economia muda todas as outras.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

Progresso offline na Unity

O progresso offline está no ApplyOfflineProgress do IdleGame, e cada linha tem um motivo:

  • DateTime.UtcNow e não DateTime.Now. O horário local muda com fuso e horário de verão. Se o jogador viaja, Now pode andar para trás uma hora e zerar o ganho, ou para frente e dar um bônus que ele não mereceu. UTC não muda.
  • Ticks em long. O JsonUtility não serializa DateTime, mas serializa long. Ticks é a contagem de intervalos de 100 nanossegundos desde o ano 1, e voltar para DateTime ou TimeSpan é direto.
  • Limite de horas. Sem limite, quem fica um mês sem abrir volta com uma fortuna que quebra a economia. Oito horas é um teto comum; muitos jogos deixam o jogador comprar upgrades que aumentam esse teto.
  • Tempo negativo ignorado. Se o relógio do celular foi atrasado, o tempo decorrido sai negativo. O if (seconds <= 0) return; impede que o jogador perca moedas.

Mostre o resultado: um painel "Você ganhou 1,23 M enquanto estava fora" lendo LastOfflineEarnings ao abrir o jogo. É o momento de recompensa que traz o jogador de volta, e é ali que entra o botão "dobrar com anúncio" em jogos mobile.

OnApplicationPause no Android. No celular, o jogador não fecha o app: ele aperta Home e o sistema mata o processo horas depois. A documentação da Unity avisa que, em plataformas móveis, o OnApplicationQuit pode não ser chamado, e recomenda salvar no OnApplicationPause. Por isso o IdleGame salva em três lugares: pausa, perda de foco e autosave a cada 30 segundos. O OnApplicationQuit fica só para o PC.

Salvar com JSON

Arquivo SaveSystem.cs:

using System.IO;
using UnityEngine;

public static class SaveSystem
{
    private static string FilePath => Path.Combine(Application.persistentDataPath, "idle_save.json");

    public static void Save(IdleState state)
    {
        string json = JsonUtility.ToJson(state);
        string tempPath = FilePath + ".tmp";
        File.WriteAllText(tempPath, json);
        if (File.Exists(FilePath)) File.Delete(FilePath);
        File.Move(tempPath, FilePath);
    }

    public static IdleState Load()
    {
        if (!File.Exists(FilePath)) return null;
        string json = File.ReadAllText(FilePath);
        return JsonUtility.FromJson<IdleState>(json);
    }
}

O Application.persistentDataPath aponta para uma pasta gravável que sobrevive a atualizações do app. O truque do arquivo temporário evita save corrompido: se o celular desligar no meio da escrita, o arquivo bom anterior continua intacto. O JsonUtility serializa double, long, int e List<int> sem configuração. O post sobre sistema de save em JSON na Unity cobre versões de save e migração quando você muda a estrutura.

Erros comuns: relógio do celular, overflow

  • Ganho offline gigante. Jogador adiantou o relógio. O teto de horas limita o estrago; para jogos com ranking, só um horário de servidor resolve de vez.
  • Perda de moedas ao voltar. Tempo negativo não tratado.
  • Progresso perdido no Android. Salvar só no OnApplicationQuit.
  • Número virando "Infinity" ou "NaN". O double passou do limite ou houve divisão por zero (taxa de crescimento igual a 1 no BulkCost). Valide a taxa no ScriptableObject.
  • Usar int para moedas. O int estoura em pouco mais de 2,1 bilhões e vira negativo. Em idle, isso acontece no primeiro dia.
  • Custo do upgrade recalculado num laço. Para compra em massa, use a fórmula da progressão geométrica.
  • UI atualizando a cada frame com string nova. Gera lixo constante. Atualize o texto num intervalo.

Próximo passo

Acrescente o prestígio: um botão que zera moedas e upgrades em troca de uma moeda especial que multiplica toda a renda futura (por exemplo, +10% por ponto de prestígio, com pontos = raiz quadrada do total ganho dividido por um milhão). É o sistema que dá vida longa ao gênero e o melhor exercício de economia que existe. Para ver a mesma estrutura em outra engine, compare com o idle clicker na Godot.

Perguntas frequentes

Como fazer progresso offline num idle game na Unity?

Ao salvar, guarde o horário em UTC (DateTime.UtcNow.Ticks). Ao abrir o jogo, calcule quanto tempo passou, limite a um máximo (por exemplo 8 horas) e multiplique pela renda por segundo. Ignore tempos negativos, que aparecem quando o jogador volta o relógio do celular.

Double é suficiente para números de jogo idle?

Para a maioria dos idle games, sim. O double vai até cerca de 1,8 x 10 elevado a 308 e guarda de 15 a 17 dígitos significativos, o que basta quando você mostra o número abreviado (1,23 T). BigInteger só é necessário se o jogo passar desse limite ou precisar de inteiros exatos com muitos dígitos.

Qual fórmula usar para o custo de upgrade num clicker?

A mais comum é custo = custo base x taxa elevado à quantidade já comprada, com taxa entre 1,07 e 1,15. A renda do upgrade cresce de forma linear e o custo cresce exponencialmente, então cada compra demora um pouco mais que a anterior, o que cria o ritmo de espera típico do gênero.

Onde salvar o progresso de um idle game no celular?

Num arquivo JSON em Application.persistentDataPath, que é uma pasta gravável que sobrevive a atualizações do app. Salve quando o app vai para segundo plano (OnApplicationPause com true) e a cada 30 segundos, porque no Android e no iOS o OnApplicationQuit pode não ser chamado.