Módulos Wifi

Jogo JawBreaker com ESP32 e WebSocket: Um Projeto Interativo

Eletrogate 29 de janeiro de 2025

Introdução

Este projeto tem como objetivo implementar o jogo conhecido como JawBreaker numa aplicação AsyncWebServer no ESP32 com interação com o Navegador via WebSocket.

O ESP32 é um microcontrolador poderoso, amplamente utilizado em projetos de IoT devido ao seu baixo custo, conectividade WiFi e capacidade de realizar multitarefas. Neste projeto, exploramos essas capacidades ao implementar o clássico jogo JawBreaker, utilizando WebSocket para criar uma comunicação em tempo real entre o ESP32 e um navegador web. O jogo é renderizado no navegador, enquanto a lógica do jogo é gerenciada pelo ESP32, criando uma experiência fluida e interativa.


Motivação

Desde o início dos anos 2000, quando tive meu primeiro contato com o JawBreaker nos dispositivos PALMTOP, fiquei fascinado pela simplicidade e diversão do jogo. Ao longo dos anos, desenvolvi diferentes versões em plataformas como SuperWaba, Java e Android. Agora, com o ESP32, pude revisitar essa paixão, integrando o jogo com tecnologia moderna, como o WebSocket e C++, e criando uma experiência interativa que aproveita o poder da web e do hardware embarcado.


Bibliotecas Utilizadas

Antes de iniciar a programação, é preciso fazer a instalação das placas da Espressif e das seguintes bibliotecas:

  • ESPAsyncWebServer (disponível no GitHub do desenvolvedor);
  • WiFiManager
  • ElegantOTA
  • AsyncTCP
  • ESP32Ping
  • ESPmDNS
  • FS
  • SPIFFS
  • WiFi
  • time
  • ArduinoJson
  • deque
  • vector
  • unordered

Com exceção da biblioteca ESPAsyncWebServer, você encontra as demais no próprio Gerenciador de Bibliotecas da Arduino IDE, sem contar que algumas, como as relacionadas ao WiFi, são instaladas juntamente com o pacote de placas da Espressif. Importante: utilizamos a versão 2.0.17 (última da versão 2) da placa ESP32 pois tivemos problemas de compatibilidade usando a versão 3.

Figura 1 – Biblioteca Principal

Se tem dúvidas na instalação das placas, siga nosso tutorial de instalação.


Diagrama do Circuito

A aplicação não utiliza nenhum outro componente e nenhuma porta do ESP32. Simplesmente utiliza os recursos de memória e processamento do ESP32 para a aplicação AsyncWebServer.

Figura 2 – Diagrama do Circuito


Sobre o Jogo JawBreaker

O jogo é composto por um tabuleiro de 10×10 preenchido aleatoriamente com bolinhas de 5 cores. O jogador deve eliminar grupos de bolinhas adjacentes (vizinhas) da mesma cor. O jogador deve agrupar um mínimo de 2 bolinhas a cada jogada. O primeiro Click agrupa enfatizando a seleção com fundo amarelo e o segundo click, dentro da área selecionada, elimina a seleção do tabuleiro ajustando a queda vertical das demais bolinhas e, caso uma coluna fique vazia, ela é eliminada e as demais colunas não vazias serão ajustadas à direita do tabuleiro. Quanto maior o agrupamento, maior é o número de pontos obtidos, onde a pontuação é dada pela equação N(N-1) onde N=Número de bolinhas na seleção. O Jogo termina quando não há mais bolinhas da mesma cor e adjacentes a selecionar. Caso o jogador consiga eliminar todas as bolinhas do tabuleiro (Restantes=0), o placar será dobrado como Bônus.

O diferencial deste projeto é que a lógica do jogo é toda processada no ESP32, enquanto a interface gráfica e a interação com o usuário ocorrem no navegador. A comunicação entre o ESP32 e o navegador é feita através de WebSocket, permitindo atualizações em tempo real sem necessidade de recarregar a página. Quando o jogador faz uma seleção, o backend calcula a nova disposição das bolinhas, ajusta o tabuleiro e devolve os resultados ao navegador.

Figura 3 – Tela Principal do Jogo num Desktop

Figura 4 – Tela com TopTen e as Estatísticas Globais

Figura 5 – Tela Principal do Jogo num Celular

Figura 6 – Tela Principal do Jogo GAMEOVER no Celular

Limitações

Devido principalmente às restrições de memória (entre 150K a 200k livres na família ESP32), o jogo foi implementado com um número máximo de 5 jogos simultâneos (MAX_GAMES=5). Caso a sexta conexão seja ativada, o usuário receberá uma mensagem informativa sugerindo tentar mais tarde. A implementação também prevê o número limitado de UNDO’s (MAX_UNDO=5) exatamente para restringir o consumo de RAM por cada instância do jogo. Além da questão da memória, a capacidade de processamento para múltiplos usuários pode ser um gargalo pois há um processamento significativo na varredura do tabuleiro para detectar GAMEOVER, contar as bolinhas restantes e a marcação recursiva de bolinhas adjacentes.

Adicionalmente e periodicamente, uma varredura por conexões perdidas e inativas é feita no ESP32 para a desconexão forçada visando a liberação de memória. Muitas vezes, o jogador fecha o Navegador sem encerrar o jogo e a alocação de memória fica ativa no ESP32 nos forçando a fazer a limpeza periódica para a liberação de recursos. Isso pode ser crítico em celulares quando o celular entra no modo de proteção de tela gerando inatividade da comunicação WebSocket. Neste caso, o ESP32 detectará a inatividade e derrubará a conexão forçando o usuário a dar refresh no Navegador e iniciar uma nova partida. Já num desktop, a inatividade só acontece com o fechamento abrupto do Navegador.

Comunicação entre o ESP32 e o Navegador

A figura 7 a seguir nos mostra o diagrama de troca de mensagens entre o ESP32 e o Navegador. As mensagens trafegam no formato JSON através de WebSocket pela rede local.

Figura 7 – Fluxo de Mensagens via WebSocket

Funções Adicionais

  1. Inclusão de Alias no DNS da Rede Local (mDNS)
  2. Atualização do Código via Interface Web (ElegantOTA)
  3. Modo de Configuração dos Parâmetros (WiFiManager)
  4. Atende até 5 jogos simultâneos (MAX_GAMES=5)
  5. Compilação condicional para três placas: ESP32, ESP32C3 MINI e XIAO ESP32S3

Os parâmetros da aplicação são armazenados no SPIFFS no formato JSON no arquivo /config.json, conforme estrutura a seguir.

Tabela de TimeZone dos Países

{
   "DnsName": "jawbreaker",
   "NTPServer": "a.st1.ntp.br",
   "Timezone": "<-03>3", 
   "usuarioOTA": "admin",
   "senhaOTA": "esp32@jaw",
   "autorebootOTA": true
})";

Os Top-Ten são também armazenados no SPIFS no formato JSON no arquivo /topten.json, conforme estrutura a seguir:

[
  {
    "name": "Dailton",
    "score": 874,
    "remaining": 16,
    "timestamp": "04/11/2024 19:12:31"
  },
  {
    "name": "Dailton",
    "score": 792,
    "remaining": 1,
    "timestamp": "03/11/2024 16:22:13"
  },
  {
    "name": "Dailton",
    "score": 778,
    "remaining": 2,
    "timestamp": "22/10/2024 13:42:10"
  },
  {
    "name": "Dailton",
    "score": 774,
    "remaining": 5,
    "timestamp": "04/11/2024 18:13:49"
  },
  {
    "name": "Dailton",
    "score": 770,
    "remaining": 16,
    "timestamp": "03/11/2024 16:23:13"
  },
  {
    "name": "Dailton",
    "score": 766,
    "remaining": 9,
    "timestamp": "04/11/2024 19:47:29"
  },
  {
    "name": "Dailton",
    "score": 702,
    "remaining": 10,
    "timestamp": "23/10/2024 20:29:25"
  },
  {
    "name": "Dailton",
    "score": 688,
    "remaining": 0,
    "timestamp": "04/11/2024 18:16:03"
  },
  {
    "name": "Dailton",
    "score": 684,
    "remaining": 9,
    "timestamp": "04/11/2024 13:11:25"
  },
  {
    "name": "Dailton",
    "score": 684,
    "remaining": 18,
    "timestamp": "04/11/2024 17:24:06"
  }
]

As Estatísticas são também armazenadas no SPIFS no formato JSON no arquivo /stat.json, conforme estrutura a seguir:

{
  "minScore": 136,
  "maxScore": 874,
  "avgScore": 346.5801953,
  "minRemaining": 0,
  "maxRemaining": 40,
  "avgRemaining": 13.85913528,
  "totalGames": 717
}

Código Fonte

A implementação do jogo foi feita modularizando o código em três arquivos. Os três arquivos devem estar na pasta do projeto para a compilação.

Módulo Função
jawbreaker.h Implementa o Header para Classe JawBreakerGame
jawbreaker.cpp Implementa a Classe JawBreakerGame propriamente dita
jawbreaker.ino Implementa o jogo propriamente dito como uma aplicação web respondendo na porta 80

 

Código para o módulo jawbreaker.h

//-----------------------------------------------------------------------------------------
// Função   : Este módulo tem como definir a Classe JawBreakerGame para permitir o acesso 
//            de múltiplos usuários numa interface Web.
//
// Autor     : Dailton Menezes
// Versão    : 1.0 Out/2024
//-----------------------------------------------------------------------------------------

#ifndef JAWBREAKER_H
#define JAWBREAKER_H

#include <vector>
#include <deque>
#include <AsyncWebSocket.h>
#include <ArduinoJson.h>                     

//--------------------------------
// Define os tamanhos do tabuleiro
//--------------------------------

#define MAX_UNDO             5               // Máximo nível de UNDO na STACK
#define BOARD_ROWS           10              // Número de linhas do tabuleiro
#define BOARD_COLS           10              // Número de colunas do tabuleiro

//---------------------------------------
// Estrutura para uma célula no tabuleiro
//---------------------------------------

struct Cell 
{
    int row;
    int col;
    int type; // Cor ou tipo de bolinha

    Cell(int r = 0, int c = 0, int t = -1) : row(r), col(c), type(t) {}
};

//-------------------------------------------------------------
// Tipo de Matriz para salvar o estado no jogo para Undo e redo
//------------------------------------------------------------

typedef int BoardState[BOARD_ROWS][BOARD_COLS];

//---------------------------------------------
// Define a Class para representar o Jogo Ativo
//---------------------------------------------

class JawBreakerGame 
{
public:
    // Construtor e destrutor
    JawBreakerGame(AsyncWebSocketClient* clientRef, AsyncWebSocket* wsRef);
    ~JawBreakerGame();

    // Métodos do jogo

    void initializeBoard();  
    void sendSelection();
    void sendBoard();
    bool removeSelectedCells();
    void sendScore(int selectionScore);
    void sendTotalScore();
    void sendGameOver(bool prompt);
    int remaining();
    bool undo();
    bool redo();
    bool isGameActive();
    void selectedRegionClear();
    int getTotalScore(); 
    int getTotalRemove();   
    void iniciarSelecionarAdjacentes(int row, int col);

private:
    // Métodos auxiliares para manipulação de pilhas e arrays
    int** getTypeArray();
    void setTypeArray(int** arr);
    void freeTypeArray(int** arr);
    int getActiveCells(int** arr);  
    int getScore(int num);      
    void clearStacks();
    void pushUndoState();
    void popUndoState();
    void pushRedoState();
    void popRedoState();
    void selecionarAdjacentes(int row, int col, int cor, bool visitado[BOARD_ROWS][BOARD_COLS]);
    void clearSelection();
    bool isGameOver();
    bool isValidCell(int row, int col);
    bool isColEmpty(int col); 
    void moveColumnRight(int col);    
    void synchronizeBoard();    
    void updateTotalScore(int selectionScore);    

    // Variáveis de jogo
    AsyncWebSocketClient* client;                  // Cliente WebSocket associado
    AsyncWebSocket* ws;                            // Referência para o WebSocket global
    int totalScore;                                // Placar total
    int totalRemove=0;                             // Total de removes por partida    
    bool gameActive;                               // Indicador de jogo ativo
    std::vector<std::vector<Cell>> board;          // Tabuleiro 10x10
    std::vector<Cell> selectedRegion;              // Região selecionada
    std::deque<int**> undoStack;                   // Pilha de estados anteriores
    std::deque<int**> redoStack;                   // Pilha de estados para refazer
    bool visitado[BOARD_ROWS][BOARD_COLS]={false}; // Controle das visitas recursivas
};

#endif // JAWBREAKER_H

Código para o módulo jawbreaker.cpp

//-----------------------------------------------------------------------------------------
// Função   : Este módulo tem como objetivo implementar a Classe JawBreakerGame para 
//            permitir o acesso de múltiplos usuários  numa interface Web.
//
// Autor     : Dailton Menezes
// Versão    : 1.0 Out/2024
//-----------------------------------------------------------------------------------------

#include "jawbreaker.h"

//----------------------------------------------------------
// Construtor: Inicializa o tabuleiro e as variáveis de jogo
//----------------------------------------------------------

JawBreakerGame::JawBreakerGame(AsyncWebSocketClient* clientRef, AsyncWebSocket* wsRef) : client(clientRef), ws(wsRef), totalScore(0), totalRemove(0), gameActive(false)
{ 
    initializeBoard();
}


//-----------------------------------------------------
// Destrutor: Libera os recursos alocados dinamicamente
//-----------------------------------------------------

JawBreakerGame::~JawBreakerGame() 
{
    clearStacks();
}

//----------------------------------
// Inicializa o tabuleiro
//----------------------------------

void JawBreakerGame::initializeBoard() 
{
    clearStacks();

    // Reinicializa o board com novas células
    board = std::vector<std::vector<Cell>>(BOARD_ROWS, std::vector<Cell>(BOARD_COLS));

    // Preenche com uma nova configuração aleatória
    for (int i = 0; i < BOARD_ROWS; ++i) 
    {
        for (int j = 0; j < BOARD_COLS; ++j) 
        {
            board[i][j] = {i, j, random(0, 5)};
        }
    }

    totalScore = 0;
    totalRemove = 0;
    gameActive = true;
}

//----------------------------------
// Desaloca as stacks de Undo e Redo
//----------------------------------

void JawBreakerGame::clearStacks() 
{
    // Limpa a pilha de desfazer
    while (!undoStack.empty()) 
    {
        freeTypeArray(undoStack.back());  // Libera a memória de cada array
        undoStack.pop_back();
    }
    // Limpa a pilha de refazer
    while (!redoStack.empty()) 
    {
        freeTypeArray(redoStack.back());  // Libera a memória de cada array
        redoStack.pop_back();
    }
}

//----------------------------------------------------
// Devolve a matriz de type do tabuleiro
//----------------------------------------------------

int** JawBreakerGame::getTypeArray() 
{
    int** typeArray = new int*[BOARD_ROWS];
    for (int i = 0; i < BOARD_ROWS; i++) 
    {
        typeArray[i] = new int[BOARD_COLS];
        for (int j = 0; j < BOARD_COLS; j++) 
        {
            // Como board[i][j] é uma instância direta de Cell, acessamos diretamente o atributo type
            typeArray[i][j] = board[i][j].type;
        }
    }
    return typeArray;
}

//-------------------------------------------------
// Remonta a matriz de type no tabuleiro
//-------------------------------------------------

void JawBreakerGame::setTypeArray(int** arr) 
{
    for (int i = 0; i < BOARD_ROWS; i++) 
    {
        for (int j = 0; j < BOARD_COLS; j++) 
        {
            // Como board[i][j] é uma instância direta, apenas atualizamos seu atributo type
            board[i][j].type = arr[i][j];
            board[i][j].row = i;
            board[i][j].col = j;
        }
    }
}

//-------------------------------------
// Desaloca a matriz de type
//-------------------------------------

void JawBreakerGame::freeTypeArray(int** arr) 
{
    for (int i = 0; i < BOARD_ROWS; i++) 
    {
        delete[] arr[i];
    }
    delete[] arr;
}

// Outras implementações de métodos podem seguir o mesmo padrão para `handleSelection`, `removeSelectedCells`, etc.

//---------------------------------------------
// Verifica se a célula está dentro dos limites
//---------------------------------------------

bool JawBreakerGame::isValidCell(int row, int col) 
{
    return row >= 0 && row < BOARD_ROWS && col >= 0 && col < BOARD_COLS;
}

//-----------------------------------------------------
// Seleciona recursivamente as bolinhas adjacentes
//-----------------------------------------------------

void JawBreakerGame::selecionarAdjacentes(int row, int col, int cor, bool visitado[BOARD_ROWS][BOARD_COLS]) {
    // Condições de limite
    if (!isValidCell(row, col) || visitado[row][col] || board[row][col].type != cor || board[row][col].type == -1) {
        return;
    }

    // Marque como visitada e adicione à `selectedRegion`
    visitado[row][col] = true;
    selectedRegion.push_back(board[row][col]);
    //Serial.printf("Adicionando célula [%d, %d] com cor %d à selectedRegion. Tamanho agora: %d\n", row, col, cor, selectedRegion.size());

    // Chama recursivamente para células adjacentes
    selecionarAdjacentes(row + 1, col, cor, visitado);
    selecionarAdjacentes(row - 1, col, cor, visitado);
    selecionarAdjacentes(row, col + 1, cor, visitado);
    selecionarAdjacentes(row, col - 1, cor, visitado);
}

//----------------
// Limpa a seleção
//----------------

void JawBreakerGame::clearSelection() 
{
    for (const auto& cell : selectedRegion) 
    {
        // Remove a classe 'selected' para restaurar o fundo padrão
        String selector = "[data-row='" + String(cell.row) + "'][data-col='" + String(cell.col) + "']";
        ws->textAll("{\"command\":\"clear\",\"selector\":\"" + selector + "\"}");
    }
    selectedRegion.clear();  // Limpa a seleção atual
}

//-------------------------------
// Envia a seleção para o cliente
//-------------------------------

void JawBreakerGame::sendSelection() 
{
    DynamicJsonDocument selectionDoc(1024);
    JsonArray selectionJson = selectionDoc.createNestedArray("selection");

    for (const auto& cell : selectedRegion) {
        JsonObject cellJson = selectionJson.createNestedObject();
        cellJson["row"] = cell.row;
        cellJson["col"] = cell.col;
    }

    String output;
    serializeJson(selectionDoc, output);
    client->text(output);  // Envia a seleção para o cliente
}

//---------------------------------------------------------------------
// Sincroniza o Tabuleiro por Gravidade e Shift Right de colunas vazias
//---------------------------------------------------------------------

void JawBreakerGame::synchronizeBoard() 
{
    // Efeito gravidade - Bolinhas caem para preencher os espaços
    
    for (int times = 0; times < 10; ++times) 
    {
        // Começa de baixo para cima
        
        for (int i = BOARD_ROWS-2; i >= 0; --i) 
        {  
            for (int j = 0; j < BOARD_COLS; ++j) 
            {
                if (board[i][j].type != -1 && board[i + 1][j].type == -1) 
                {
                    // Move a bolinha para baixo
                    board[i + 1][j] = board[i][j];
                    board[i][j].type = -1;
                    board[i + 1][j].row = i + 1;  // Atualiza a nova linha
                }
            }
        }
    }

    // Shift para a direita se uma coluna ficar vazia
    
    for (int times = 0; times < BOARD_ROWS; ++times) 
    {
        for (int col = 1; col < BOARD_COLS; ++col) 
        {
            if (isColEmpty(col)) 
            {
                moveColumnRight(col);
            }
        }
    }
}

//----------------------------------
// Verifica se uma coluna está vazia
//----------------------------------

bool JawBreakerGame::isColEmpty(int col) 
{
    for (int row = 0; row < BOARD_ROWS; ++row) 
    {
        if (board[row][col].type != -1) return false;
    }
    return true;
}

//----------------------------
// Move uma coluna Shift Right
//----------------------------

void JawBreakerGame::moveColumnRight(int col) 
{
    for (int row = 0; row < BOARD_ROWS; ++row) 
    {
        if (board[row][col - 1].type != -1) 
        {
            // Move a bolinha para a direita
            board[row][col] = board[row][col - 1];
            board[row][col - 1].type = -1;
            board[row][col].col = col;  // Atualiza a nova coluna
        }
    }
}

//------------------------------------------------
// Calcula o N. de bolinhas restantes no tabuleiro
//------------------------------------------------

int JawBreakerGame::remaining() 
{
    int count = 0;
    for (int i = 0; i < BOARD_ROWS; ++i) 
    {
        for (int j = 0; j < BOARD_COLS; ++j) 
        {
            if (board[i][j].type != -1) 
            {
                count++;
            }
        }
    }
    return count;
}

//--------------------------------
// Remove as bolinhas selecionadas
//--------------------------------

bool JawBreakerGame::removeSelectedCells() 
{
    // Salva o estado atual na `undoStack`

    pushUndoState();

    // Aqui ocorre a remoção das células selecionadas e a atualização do tabuleiro
    
    for (Cell& cell : selectedRegion) 
    {
        board[cell.row][cell.col].type = -1;  // Marca como removido
    }

    // Sincroniza o tabuleiro após a remoção
    
    synchronizeBoard();
    totalScore += selectedRegion.size() * (selectedRegion.size() - 1);  // Atualiza o totalScore    

    // Limpa a região selecionada
    
    selectedRegion.clear();

    // Incrementa o número de removes

    totalRemove++;
    gameActive = !isGameOver();

    // Verifica se o jogo acabou e se restarem zero bolinhas para duplicar o placar
    
    if (!gameActive && remaining()==0) 
    {
       totalScore *= 2;       
    }    
    return !gameActive;  // Retorna se o jogo terminou
}

//--------------------------
// Verifica se terminou jogo
//--------------------------

bool JawBreakerGame::isGameOver() 
{
    for (int i = 0; i < BOARD_ROWS; ++i) 
    {
        for (int j = 0; j < BOARD_COLS; ++j) 
        {
            // Verifica células com tipo válido
            
            if (board[i][j].type != -1) 
            {
                // Verifica células adjacentes abaixo e à direita
                
                if ((i < BOARD_ROWS - 1 && board[i][j].type == board[i + 1][j].type) || 
                    (j < BOARD_COLS - 1 && board[i][j].type == board[i][j + 1].type)) 
                {
                    return false;  // Ainda há uma combinação possível
                }
            }
        }
    }
    return true;  // GAMEOVER quando nenhuma combinação é encontrada
}

//---------------------------------
// Envia o tabuleiro para o cliente
//---------------------------------

void JawBreakerGame::sendBoard() 
{
    DynamicJsonDocument doc(4096);
    JsonArray boardArray = doc.createNestedArray("board");

    for (int i = 0; i < BOARD_ROWS; ++i) 
    {
        JsonArray rowArray = boardArray.createNestedArray();
        for (int j = 0; j < BOARD_COLS; ++j) 
        {
            JsonObject cellObject = rowArray.createNestedObject();
            cellObject["row"] = i;
            cellObject["col"] = j;
            cellObject["type"] = board[i][j].type;
        }
    }

    String output;
    serializeJson(doc, output);
    client->text(output);  // Envia o tabuleiro para o cliente via WebSocket
}

//--------------------------
// Envia o valor da seleção
//--------------------------

void JawBreakerGame::sendScore(int selectionScore) 
{
    DynamicJsonDocument doc(1024);
    doc["score"] = selectionScore;  // Envia o score atual da seleção
    String output;
    serializeJson(doc, output);
    client->text(output);  // Envia o Score da seleção atualizado
}

//-------------------------
// Envia o valor do Placar
//-------------------------

void JawBreakerGame::sendTotalScore() 
{
    DynamicJsonDocument doc(1024);
    doc["totalScore"] = totalScore;

    String output;
    serializeJson(doc, output);
    client->text(output);  // Envia o placar total para o cliente
}

//-----------------
// Envia GAME OVER
//-----------------

void JawBreakerGame::sendGameOver(bool prompt)
{
    int remainingBalls = remaining();  // Calcula o número de bolinhas restantes

    DynamicJsonDocument doc(1024);

    if (remainingBalls == 0) {
        doc["message"] = "GAME OVER com BÔNUS!";
    } else {
        doc["message"] = "GAME OVER - Restantes=" + String(remainingBalls);
    }

    doc["prompt"] = prompt;
    doc["score"] = totalScore;
    doc["remaining"] = remainingBalls;

    Serial.printf("GameOver Score Conexão[%d]: %d Restantes: %d Prompt: %d\n",client->id(),totalScore,remainingBalls,prompt);

    String output;
    serializeJson(doc, output);
    client->text(output);  // Envia mensagem de Game Over

    sendTotalScore();  // Envia placar atualizado
}

//--------------
// Trata o Undo
//--------------

bool JawBreakerGame::undo() 
{
    if (!undoStack.empty()) {
        selectedRegion.clear();

        // Salva o estado atual na pilha de redo
        int** current = getTypeArray();
        int** popped = undoStack.back();
        undoStack.pop_back();

        // Atualiza o placar
        totalScore -= getScore(getActiveCells(popped) - getActiveCells(current));

        // Envia o estado atual para a pilha de redo e restaura o estado anterior
        redoStack.push_back(current);
        setTypeArray(popped);

        // Envia o tabuleiro atualizado para o cliente
        sendBoard();
        sendTotalScore();

        return true;
    }
    return false;
}

//--------------
// Trata o Redo
//--------------

bool JawBreakerGame::redo() {
    if (!redoStack.empty()) {
        selectedRegion.clear();

        // Salva o estado atual na pilha de undo
        int** current = getTypeArray();
        int** popped = redoStack.back();
        redoStack.pop_back();

        // Atualiza o placar
        totalScore += getScore(getActiveCells(current) - getActiveCells(popped));

        // Envia o estado atual para a pilha de undo e restaura o estado anterior
        undoStack.push_back(current);
        setTypeArray(popped);

        // Envia o tabuleiro atualizado para o cliente
        sendBoard();
        sendTotalScore();

        return true;
    }
    return false;
}

//--------------------------------------------
// Função para atualizar o valor do TotalScore
//--------------------------------------------

void JawBreakerGame::updateTotalScore(int selectionScore) 
{
    totalScore += selectionScore;  // Soma o score da seleção ao total
}

//------------------------------------------------------
// Funcão de fazer o empilhamento do tabuleiro para UNDO 
//------------------------------------------------------

void JawBreakerGame::pushUndoState() 
{
    // Verifica se a pilha de desfazer atingiu o limite máximo
    
    if (undoStack.size() >= MAX_UNDO) 
    {
        // Remove o estado mais antigo para dar lugar ao novo
        
        freeTypeArray(undoStack.front());
        undoStack.pop_front();
    }
    
    // Adiciona o estado atual do tabuleiro à pilha de desfazer
    
    undoStack.push_back(getTypeArray());
}

//---------------------------------------------------------
// Funcão de fazer o desempilhamento do tabuleiro para UNDO
//---------------------------------------------------------

void JawBreakerGame::popUndoState() 
{
    if (!undoStack.empty()) 
    {
        int** lastState = undoStack.back();
        undoStack.pop_back();
        pushRedoState();
        setTypeArray(lastState);
        freeTypeArray(lastState);
    }
}

//------------------------------------------------------
// Funcão de fazer o empilhamento do tabuleiro para REDO 
//------------------------------------------------------

void JawBreakerGame::pushRedoState() 
{
    if (redoStack.size() >= MAX_UNDO) 
    {
        // Remove o estado mais antigo para dar lugar ao novo
        
        freeTypeArray(redoStack.front());
        redoStack.pop_front();
    }
    redoStack.push_back(getTypeArray());
}

//---------------------------------------------------------
// Funcão de fazer o desempilhamento do tabuleiro para REDO
//---------------------------------------------------------

void JawBreakerGame::popRedoState() 
{
    if (!redoStack.empty()) 
    {
        int** lastRedoState = redoStack.back();
        redoStack.pop_back();
        pushUndoState();
        setTypeArray(lastRedoState);
        freeTypeArray(lastRedoState);
    }
}

//---------------------------
// Limpa a seleção existente
//---------------------------

void JawBreakerGame::selectedRegionClear()
{
   selectedRegion.clear();
}

//---------------------------
// Retorna o Placar do Jogo
//---------------------------

int JawBreakerGame::getTotalScore() 
{
    return totalScore;
}

//----------------------------
// Retorna o total de Remoções
//----------------------------

int JawBreakerGame::getTotalRemove()
{
    return totalRemove;
}

//----------------------------------------------------------
// Prepara a fazer a seleção recursiva de células adjacentes
//----------------------------------------------------------

void JawBreakerGame::iniciarSelecionarAdjacentes(int row, int col) {
    int corInicial = board[row][col].type;  // Cor de referência para seleção
    bool visitado[BOARD_ROWS][BOARD_COLS] = {false};
    selecionarAdjacentes(row, col, corInicial, visitado);
}

//------------------------------------------------
// Retorna o Número de Células Ativas no Tabuleiro
//------------------------------------------------

int JawBreakerGame::getActiveCells(int** arr) {
    int total = 0;
    for (int i = 0; i < BOARD_ROWS; i++) {
        for (int j = 0; j < BOARD_COLS; j++) {
            if (arr[i][j] != -1) {
                total++;
            }
        }
    }
    return total;
}

//-------------------------------
// Retorna o valor de uma Seleção
//-------------------------------

int JawBreakerGame::getScore(int num) {
    return num * (num - 1);
}

//-----------------------------
// Retorna se o Jogo está Ativo
//-----------------------------

bool JawBreakerGame::isGameActive()
{
    return gameActive;
}

Código para o módulo jawbreaker.ino

//-----------------------------------------------------------------------------------------
// Função   : Este programa tem como objetivo implementar o Jogo JawBreaker numa aplicação
//            AsyncWebServer com interação com o Navegador via WebSocket
//
// Objetivo do Jogo
//
//   O jogo é composto por um tabuleiro de 10x10 preenchido aleatoriamente com bolinhas de 
//   5 cores. O objetivo do jogo é agrupar um mínimo de 2 bolas da mesma cor e adjacentes   
//   para eliminar do tabuleiro. O primeiro Click agrupa enfatizando a seleção com fundo  
//   amarelo e o segundo click, dentro da área selecionada, elimina a seleção do tabuleiro    
//   ajustando a queda vertical das bolinas e, caso uma coluna fique vazia, as colunas não   
//   vazias serão ajustadas à direita do tabuleiro. Quanto maior o agrupamento, maior é o   
//   número de pontos obtidos, onde a pontuação é dada pela equação N(N-1) onde N=Número  
//   de bolinhas na seleção. O Jogo termina quando não há mais bolinhas da mesma cor e  
//   adjacentes a selecionar. Caso o jogador consiga eliminar todas as bolinhas do   
//   tabuleiro (Restantes=0), o placar será dobrado como Bônus.
//
// Funções adicionais
//
//   1) Inclusão de Alias no DNS da Rede Local (mDNS)
//   2) Atualização do Código via Interface Web (ElegantOTA)
//   3) Modo de Configuração dos Parãmetros (WiFiManager)
//   4) Atende até 5 jogos simultâneos (MAX_GAMES=5)
//
// Autor     : Dailton Menezes
// Versão    : 1.0 Out/2024 - Completo com WM e OTA, int**, DEQUE, MAX_UNDO
// Referência: https://www.minijuegos.com/juego/jawbreaker-1-0
//-----------------------------------------------------------------------------------------

#include <Arduino.h>                         // Biblioteca Arduino
#include <WiFi.h>                            // Biblioteca WiFi
#include <AsyncTCP.h>                        // Biblioteca AsyncTCP usado pelo Web
#include <ESP32Ping.h>                       // Biblioteca Ping
#include <FS.h>                              // Biblioteca FileSystem
#include <SPIFFS.h>                          // Biblioteca SPIFFS
#include <WiFiManager.h>                     // Biblioteca WiFi Manager
#include <ESPAsyncWebServer.h>               // Biblioteca Asynch Web Server
#include <ArduinoJson.h>                     // Biblioteca JSON para comunicação e parãmetros   
#include <ESPmDNS.h>                         // Biblioteca para mDNS
#include <deque>                             // Biblioteca para manuseio de Stack
//#include <stack>                           // Biblioteca para manuseio de Stack
#include <vector>                            // Biblioteca para manuseio de Vector
#include <unordered_map>                     // Biblioteca para manuseio de Lista
#include <time.h>                            // Biblioteca Time para manipulação de data/hora
#include <ElegantOTA.h>                      // Biblioteca para atualização via Web
#include "jawbreaker.h"                      // Definições do Jogo

//-------------------------------------
// Define os Pinos usados pelo programa
//-------------------------------------

#if defined(CONFIG_IDF_TARGET_ESP32C3)       // ESP32C3 Mini
  #define pinWIFI             8              // Pino do Led BuiltIn interno para indicar Wifi ON/OFF
  #define Led_Wifi_ON         LOW            // No ESP32C3 LOW ativa
  #define Led_Wifi_OFF        HIGH           // No ESP32C3 HIGH desativa  
  #define Boot_Pin            9              // Pino do botão para forçar a entrada no WifiManager
#elif defined(CONFIG_IDF_TARGET_ESP32S3)     // XIAO ESP32S3
  #define pinWIFI             21             // Pino do USER LED interno para indicar Wifi ON/OFF
  #define Led_Wifi_ON         LOW            // No ESP32S3 LOW ativa
  #define Led_Wifi_OFF        HIGH           // No ESP3233 HIGH desativa  
  #define Boot_Pin            0              // Pino do botão para forçar a entrada no WifiManager  
#elif defined(ESP32)                         // ESP32 30 pinos
  #define pinWIFI             2              // Pino do Led BuiltIn interno para indicar Wifi ON/OFF
  #define Led_Wifi_ON         HIGH           // No ESP32 LOW ativa
  #define Led_Wifi_OFF        LOW            // No ESP32 HIGH desativa  
  #define Boot_Pin            0              // Pino do botão para forçar a entrada no WifiManager
#endif
#define MAX_EDIT_LEN         30              // Tamanho máximo de campos de EDIT
#define MAX_NUM_LEN          4               // Tamanho máximo de campos NUMÉRICO
#define MAX_GAMES            5               // Máximo número de jogos ativos
#define ESP_DRD_USE_SPIFFS   true            // Uso com SPIFFS
#define JSON_CONFIG_FILE     "/config.json"  // Arquivo JSON de configuração
#define JSON_TOPTEN_FILE     "/topten.json"  // Arquivo JSON do TopTen
#define JSON_STAT_FILE       "/stats.json"   // Arquivo JSON das Estatísticas
#define DEFAULT_NTP_SERVER   "a.st1.ntp.br"  // Servidor NTP do Brasil
#define DEFAULT_TZ_INFO      "<-03>3"        // TimeZone do Brasil
#define DEFAULT_DNS_NAME     "jawbreaker"    // Nome para adiciaonar o mDNS
#define ESP_getChipId()      ((uint32_t)ESP.getEfuseMac() // Simular ID da placa ESP
#define USER_UPDATE          "admin"         // Usuário para atualização via OTA
#define PASS_UPDATE          "esp32@jaw"     // Senha para atualização via OTA
#define DEFAULT_PASS_AP      "12345678"      // Senha default do modo AP WifiManager
#define TIMERECONEXAO        60000           // Tempo para tentar reconectar a Internet  
//#define DEBUG                                // Usado para Debug do Programa

//-------------------------------------
// Estrutura de um TopTen
// ------------------------------------

struct TopTenEntry {
    String name;
    int score;
    int remaining;
    String timestamp;
};

//-------------------------------------
// Estrutura das Estatísticas Globais
// ------------------------------------

struct GameStats {
    int minScore;
    int maxScore;
    double avgScore;
    int minRemaining;
    int maxRemaining;
    double avgRemaining;
    int totalGames;

    // Construtor padrão
    GameStats()
        : minScore(INT_MAX), maxScore(0), avgScore(0.0),
          minRemaining(INT_MAX), maxRemaining(0), avgRemaining(0.0),
          totalGames(0) {}
};

//------------------
// Variáveis globais
//------------------

AsyncWebServer server(80);                                       // Servidor Web na porta 80
AsyncWebSocket ws("/ws");                                        // WebSocket para comunicação
std::unordered_map<uint32_t, JawBreakerGame*> gameSessions;      // Lista de Sessões jogando
std::vector<TopTenEntry> topTen;                                 // Lista de TopTen
GameStats stats;                                                 // Estatísticas Globais do Jogo
portMUX_TYPE toptenMux = portMUX_INITIALIZER_UNLOCKED;           // Semáforo para atualização do TopTen
portMUX_TYPE statsMux  = portMUX_INITIALIZER_UNLOCKED;           // Semáforo para atualização da Stats
IPAddress ip (1, 1, 1, 1);                                       // The remote ip to ping, DNS do Google
unsigned long semInternet;                                       // Momento da queda da Internet
bool lastInternet;                                               // Última verificação da internet
bool atualInternet;                                              // Se tem internet no momento
unsigned long lastReconexao=0;                                   // Última tentativa reconexão da Internet
volatile bool buttonState = false;                               // Estado do botão Boot para Reconfiguração do WiFi
JsonDocument dbParm;                                             // Base de dados de softwares

//---------------------------------------------
// Variáveis para controle do OTA
//---------------------------------------------

bool autoRebootOTA     = true;                                   // Se deve fazer autoreboot após a atualização OTA
char user_OTA[MAX_EDIT_LEN] = USER_UPDATE;                       // Usuário para atualização OTA
char pass_OTA[MAX_EDIT_LEN] = PASS_UPDATE;                       // Senha para atualização OTA
char val_autoreboot[2] = "1";                                    // AutoRebbot Default 

//---------------------------------------------
// Variáveis para controle do WifiManger/OTA
//---------------------------------------------

WiFiManager wm;                                                  // Define o Objeto WiFiManager
bool shouldSaveConfig = false;                                   // Flag se deve persistir os parãmetros
char NTP_SERVER[MAX_EDIT_LEN+1] = DEFAULT_NTP_SERVER;            // Servidor NTP
char TZ_INFO[MAX_EDIT_LEN+1]    = DEFAULT_TZ_INFO;               // String do TimeZone 
char DNS_NAME[MAX_EDIT_LEN+1]   = DEFAULT_DNS_NAME;              // Nome Default para o DNS
char ssid_config[MAX_EDIT_LEN+1];                                // SSID para o modo  AP de Configuração 
char pass_config[] = DEFAULT_PASS_AP;                            // Senha para o modo AP de Configuração
WiFiManagerParameter custom_dnsname("DnsName", "Informe o DNSNAME (< 30)", DNS_NAME, MAX_EDIT_LEN);           // Parâmetro NTP Server
WiFiManagerParameter custom_ntpserver("NTPServer", "Informe o NTP Server (< 30)", NTP_SERVER, MAX_EDIT_LEN);           // Parâmetro NTP Server
WiFiManagerParameter custom_timezone("Timezone", "Informe o String Timezone (< 30)", TZ_INFO, MAX_EDIT_LEN);           // Parâmetro Timezone 
WiFiManagerParameter custom_user_ota("Usuario", "Informe o Usuário para Atualizações (< 15)", user_OTA, MAX_EDIT_LEN); // Parâmetro Nome  do Usuário OTA
WiFiManagerParameter custom_pass_ota("Senha", "Informe a Senha para Atualizações (< 15)", pass_OTA, MAX_EDIT_LEN);     // Parâmetro Senha do Usuário OTA
WiFiManagerParameter custom_autoreboot_ota("AutoReboot", "AutoReboot após Atualizações (0 ou 1)", val_autoreboot, 1);  // Parâmetro AutoRebbot

//--------------------------------
// Prototipação das funções usadas
//--------------------------------

// Wifi

void WiFiEvent(WiFiEvent_t event);

// WifiManager

void Check_WiFiManager(bool forceConfig);  // Inicialização/Configuração  WiFi Manager no ESP32
void saveConfigFile();                     // Persiste CPUID e Intervalo no SPIFFS do ESP32
bool loadConfigFile();                     // Recupera CPUID e Intervalo do SPIFFS do ESP32
void saveConfigCallback();                 // Callback para informação do processo de configuração WiFi
void configModeCallback(WiFiManager *myWiFiManager); // Callback para WifiManager 

// Auxiliares

String expandeHtml(String html);           // Rotina para macro expansão do HTML
void printFreeRAM(String context);         // Rotina para mostrar RAM livre associado com Contexto
void showConsole(String contexto);         // Rotina para mostrar um Contexto (para debug)
String getTimeStamp();                     // Rotina para obter o timestamp do relógio interno
bool getNTPtime(int sec);                  // Rotina para obter Timestamp do servidor NTP
void migrateTopTenTimestamps();            // Rotina para migração de layout do JSON (uso esporádico)
void buttonISR();                          // Rotina de Tratamento da Interrupção do Botão Boot
bool setDNSNAME(String nome);              // Define o HostName como DNSNAME
void deleteFile(const char* path);         // Rotina paara deletar um arquivo do SPIFS (uso esporádico)

// Topten

void addTopTen(String name, int score, int remaining, String timestamp);
void saveTopTen();
void loadTopTen();
bool isTopTenEligible(int score, int remaining);
void sendTopTen(AsyncWebSocketClient* client);

// Stat

void updateStats(int score, int remaining);
void saveStats();
void loadStats();
void sendStats(AsyncWebSocketClient* client);

//------------------------------------
// Define o JSON Default dos Softwares
//------------------------------------

const char dbDefault[] PROGMEM = R"(
{
   "DnsName": "jawbreaker",
   "NTPServer": "a.st1.ntp.br",
   "Timezone": "<-03>3", 
   "usuarioOTA": "admin",
   "senhaOTA": "esp32@jaw",
   "autorebootOTA": true
})";

//------------------------------------
// Html Principal para o Jogo
//------------------------------------

const char htmlContent[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="pt-br">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>JawBreaker Game</title>
    <style>
        * {
            box-sizing: border-box;
        }

        body {
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            min-height: 100vh;
            margin: 0;
            padding-bottom: 20px;
            font-family: Arial, sans-serif;
        }

        header {
            width: 100%;
            background-color: black;
            color: white;
            text-align: center;
            padding: 10px 0;
            margin-bottom: 20px;
        }

        #topDiv {
            display: flex;
            justify-content: center;
            margin-bottom: 20px;
            border: 2px solid black;
            padding: 10px;
            border-radius: 10px;
            width: 100%;
            max-width: 400px;
        }

        #topDiv button {
            margin: 5px;
            padding: 5px 10px;
            font-size: 12px;
            flex: 1;
            max-width: 80px;
        }

        #boardDiv {
            display: grid;
            grid-template-columns: repeat(10, 1fr);  /* Cria 10 colunas */
            gap: 2px;  /* Espaço entre as bolinhas */
            width: 100%;
            max-width: 400px;
            aspect-ratio: 1 / 1;
            margin: 0 auto;
            padding: 0 1px;
        }

        .ball-container {
            position: relative;
            width: 100%;
            height: 100%;
            cursor: pointer; /* Indica que a área é clicável */
            outline: none; /* Remove o foco visual (borda ou retângulo) */
            user-select: none; /* Impede a seleção de texto ou elementos */
            -webkit-tap-highlight-color: transparent; /* Remove destaque em dispositivos móveis */            
        }

        .ball-container.selected {
            background-color: yellow;  /* O fundo amarelo será aplicado ao contêiner */
        }
        
        .ball {
            border-radius: 50%;
            width: 100%;
            height: 100%;
            position: relative;
            z-index: 2;
            transition: none;
        }

        .ball.selected {
            background-color: yellow;  /* Aplica o fundo amarelo quando selecionado */
            transition: none;
        }

        #bottomDiv {
            display: flex;
            justify-content: space-between;
            width: 100%;
            max-width: 400px;
            margin-top: 20px;
            border: 2px solid black;
            padding: 10px;
            border-radius: 10px;
        }

        #scorePanel, #selectionPanel {
            width: 50%;
            text-align: center;
        }

        /* Ajustes para telas menores */
        @media (max-width: 420px) {
            #topDiv, #bottomDiv {
                max-width: 90vw;
            }

            #topDiv button {
                max-width: 70px;
                font-size: 10px;
            }
        }

        table {
            width: 100%;
            border-collapse: collapse;
            margin: 20px 0;
        }
        th {
            background-color: #333;
            color: white;
            padding: 10px;
            text-align: left;
        }
        td {
            padding: 8px;
            text-align: left;
        }
        tr:nth-child(even) {
            background-color: #f2f2f2;
        }
        tr:nth-child(odd) {
            background-color: #e9e9e9;
        }
        .table-title {
            background-color: #444;
            color: white;
            font-size: 18px;
            padding: 10px;
        }
        
    </style>
</head>
<body>

    <header>
        <h2>Jogo JawBreaker V1.0</h2>
    </header>

    <div id="topDiv">
        <button id="startBtn">Iniciar</button>
        <button id="undoBtn">Undo</button>
        <button id="redoBtn">Redo</button>
        <button id="topTenBtn">TopTen</button>
        <button id="statBtn">Stat</button>
    </div>

    <div id="boardDiv"></div>

    <!-- Div para mostrar a mensagem de Game Over -->
    <div id="gameOverMessage" style="color: red; font-weight: bold; text-align: center; margin-top: 20px;"></div>    

    <div id="bottomDiv">
        <div id="scorePanel">Placar: 0</div>
        <div id="selectionPanel">Seleção: 0</div>
    </div>

    <div id="topTenList" style="display: none;">
        <h3>Top Ten</h3>
    </div>

    <div id="statList" style="display: none;">
        <h3>Estatísticas</h3>
    </div>

    <script>
        // Definição global da variável WebSocket
        let ws = new WebSocket('ws://' + location.hostname + '/ws');
        
        let gameStarted = false;      // Flag para fim de jogo 
        let isGameOver = false;       // Flag para bloquear interações após Game Over
        let currentSelection = [];    // Armazena a seleção atual
        let selectionActive = false;  // Flag de controle para verificar se há uma seleção ativa
        let lastSelection = [];       // Armazena a última seleção para comparação
        let confirmRequired = true;   // Variável de controle para a confirmação

        ws.onopen = function() {
            console.log("Conectado ao servidor WebSocket");
        };

        ws.onclose = function () {
            console.log("Conexão WebSocket fechada.");
        };
    
        ws.onerror = function (error) {
            console.error("Erro no WebSocket:", error);
        };        

        ws.onmessage = function(event) {
            const data = JSON.parse(event.data);
            console.log("Dados recebidos do WebSocket:", data);

            // Verifica se é uma mensagem de ocupado

            if (data.command === "gameStatus" && data.status === "occupied") 
            {
                  alert("Máximo N. de Usuários atingido. Tente novamente mais tarde.");
            }  
            else if (data.command === "gameStatus" && data.status === "started") 
            {
               document.getElementById('gameOverMessage').textContent = ''; 
               document.getElementById('startBtn').textContent = 'Encerrar';
               gameStarted = true;
               isGameOver = false;  // Reseta o bloqueio de interação
               confirmRequired = true; 
            }
            else if (data.command === "error")
            {
               showError(data.message);  // Exibe erro para Undo/Redo inválidos
            }
            else if (data.command === "topTenData") 
            {
                updateTopTenUI(data.entries);
            } 
            else if (data.command === "statData") 
            {
                updateStatUI(data.stats);
            }
            else if (data.command === "refreshStats")    
            {
                refreshStatsIfVisible();
            }
            else if (data.command === "refreshTopTen")    
            {
                refreshTopTenIfVisible();
            }            
            
            // Verifica se a mensagem contém um tabuleiro para atualização
            if (data.board) {
                console.log("Atualizando o tabuleiro.");
                updateBoard(data.board);  // Atualiza o tabuleiro
            }
        
            // Verifica se há uma seleção para destacar
            if (data.selection) {
                highlightSelection(data.selection);  // Destaque a seleção recebida do ESP32
            }

            if (data.command === "clear") {
                const element = document.querySelector(data.selector);
                if (element) {
                    element.classList.remove('selected');
                }
            }
        
            // Atualiza o placar se estiver presente
            if (data.score !== undefined) {
                document.getElementById('selectionPanel').textContent = "Seleção: 0";  // Reseta o score após remoção
            }

            if (data.totalScore !== undefined) {
                document.getElementById('scorePanel').textContent = "Placar: " + data.totalScore;
            }     
                   
            // Exibe mensagem de Game Over e bloqueia interações se o jogo terminou
            if (data.message && data.message.startsWith("GAME OVER")) {
                console.log("Game Over recebido.");
                document.getElementById('gameOverMessage').textContent = data.message;
                confirmRequired = false; 
                document.getElementById('startBtn').click();  // Simula o clique no botão "Encerrar"
                isGameOver = true;
        
                let shouldPrompt = Boolean(data.prompt);  // Renomeia a variável para evitar conflitos
                let score = data.score;
                let remaining = data.remaining;
        
                console.log(`Prompt: ${shouldPrompt}, Score: ${score}, Restantes: ${remaining}`);
                console.log(typeof window.prompt);  // Deve exibir "function"
        
                if (shouldPrompt) {
                    let playerName = window.prompt("Parabéns! Você entrou no Top 10! Digite seu nome:");
                    if (playerName) {
                        let addTopTenRequest = {
                            command: "addTopTen",
                            name: playerName,
                            score: score,
                            remaining: remaining,
                            timestamp: getFormattedTimestamp()
                        };
                        ws.send(JSON.stringify(addTopTenRequest));
                    }
                }
       
                const hasBonus = data.message.includes("BÔNUS");
                playGameOverSound(hasBonus);  // Toca som apropriado   
            }   

        };

        document.getElementById('startBtn').addEventListener('click', function() {
            if (this.textContent == 'Iniciar')
            {
               const message = { command: 'start' };
               ws.send(JSON.stringify(message));
            }
            else
            {

                // Verifica se é necessário confirmar o encerramento do jogo
                if (confirmRequired) {
                    const userConfirmed = confirm("Tem certeza de que deseja encerrar o jogo?");
                    if (!userConfirmed) {
                        return;  // Sai se o usuário cancelar
                    }
                }
             
                const message = { command: 'end' };
                ws.send(JSON.stringify(message));
                this.textContent = 'Iniciar';
                gameStarted = false;
            }
         });

        document.getElementById('undoBtn').addEventListener('click', function() {
            const message = { command: 'undo' };
            ws.send(JSON.stringify(message));
        });
        
        document.getElementById('redoBtn').addEventListener('click', function() {
            const message = { command: 'redo' };
            ws.send(JSON.stringify(message));
        });

        document.getElementById("topTenBtn").onclick = () => {
            let topTenDiv = document.getElementById("topTenList");
            topTenDiv.style.display = topTenDiv.style.display === "none" ? "block" : "none";
            requestTopTen();            
        };        

        document.getElementById("statBtn").onclick = () => {
            let statDiv = document.getElementById("statList");
            statDiv.style.display = statDiv.style.display === "none" ? "block" : "none";
            requestStat();            
        };         

        function updateBoard(board) {
            const boardDiv = document.getElementById('boardDiv');
            boardDiv.innerHTML = '';  // Limpa o tabuleiro
        
            board.forEach((row, rowIndex) => {
                row.forEach((cell, colIndex) => {
                    const ballContainer = document.createElement('div');
                    ballContainer.classList.add('ball-container');
                    ballContainer.dataset.row = rowIndex;
                    ballContainer.dataset.col = colIndex;
        
                    // Adiciona o evento de clique ao contêiner
                    ballContainer.addEventListener('click', () => handleCellClick(rowIndex, colIndex));
        
                    if (cell.type !== -1) {
                        const ball = document.createElement('div');
                        ball.classList.add('ball');
                        ball.style.backgroundColor = getColor(cell.type);
                        ball.dataset.row = rowIndex;
                        ball.dataset.col = colIndex;
                        ballContainer.appendChild(ball);
                    } else {
                        ballContainer.style.backgroundColor = 'transparent';
                    }
        
                    boardDiv.appendChild(ballContainer);
                });
            });
        }

        function handleCellClick(row, col) {
            if (isGameOver) {
                return; // Bloqueia interações após Game Over
            }
        
            const clickedInSelection = currentSelection.some(pos => pos.row === row && pos.col === col);
        
            if (currentSelection.length > 1 && clickedInSelection) {
                // Se o clique foi em qualquer bolinha da seleção, remove
                removeSelection();
            } else {
                // Se não houver seleção ou se clicou fora da seleção, cria nova seleção
                clearSelection(); // Limpa qualquer seleção anterior
                currentSelection = []; // Limpa a seleção atual
        
                const message = {
                    command: 'select',
                    row: row,
                    col: col
                };
                ws.send(JSON.stringify(message)); // Envia a nova seleção para o ESP32
            }
        }

        function removeSelection() {
            currentSelection.forEach(ballPos => {
                const ballContainer = document.querySelector(`[data-row="${ballPos.row}"][data-col="${ballPos.col}"]`);
                if (ballContainer) {
                    ballContainer.innerHTML = '';
                    ballContainer.style.backgroundColor = 'transparent';
                }
            });
        
            const selectionSize = currentSelection.length;
            document.getElementById('scorePanel').textContent = "Placar: " + (selectionSize * (selectionSize - 1));
        
            ws.send(JSON.stringify({ command: 'remove' }));
            currentSelection = [];
        }

        function highlightSelection(selection) {
            currentSelection = selection;
            if (currentSelection.length > 1)
            {
               selection.forEach(ballPos => {
                   const ballContainer = document.querySelector(`[data-row="${ballPos.row}"][data-col="${ballPos.col}"]`);
                   if (ballContainer) {
                       ballContainer.classList.add('selected');
                   }
               });
            
               const selectionSize = selection.length;
               document.getElementById('selectionPanel').textContent = "Seleção: " + (selectionSize * (selectionSize - 1));
            }
        }
        
        function clearSelection() {
            const selectedContainers = document.querySelectorAll('.ball-container.selected');
            selectedContainers.forEach(container => container.classList.remove('selected'));
        }

        function getColor(type) {
            switch(type) {
                case 0: return '#ff0000';  // Vermelho Vivo
                case 1: return '#008000';  // Verde
                case 2: return '#000080';  // Azul
                case 3: return '#804000';  // Marrom
                case 4: return '#404040';  // Cinza escuro
                default: return 'transparent';  // Caso a bolinha tenha sido removida (ou outro tipo inválido)
            }
        }

        function checkWebSocketState() {
            if (ws.readyState !== WebSocket.OPEN) {
                console.error("WebSocket não está mais aberto. Estado atual:", socket.readyState);
                alert("Conexão WebSocket perdida!");
            }
        }

        function playGameOverSound(hasBonus) {
            const audioContext = new (window.AudioContext || window.webkitAudioContext)();
            const oscillator = audioContext.createOscillator();
            const gainNode = audioContext.createGain();
        
            // Conectar os elementos de áudio
            oscillator.connect(gainNode);
            gainNode.connect(audioContext.destination);
        
            // Configurações do som
            oscillator.type = hasBonus ? 'square' : 'sine'; // Som mais "efusivo" para bônus
            oscillator.frequency.setValueAtTime(hasBonus ? 880 : 440, audioContext.currentTime); // Frequências diferentes
        
            // Controla o volume e duração
            gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
            gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 1);
        
            // Inicia e para o som após 1 segundo
            oscillator.start();
            oscillator.stop(audioContext.currentTime + 1);
        }

        function requestTopTen() {
            console.log("Solicitação de TopTen enviada.");  // Log para depuração
            let request = { command: "getTopTen" };
            ws.send(JSON.stringify(request));
        }        

        function requestStat() {
            console.log("Solicitando Estatísticas.");
            let request = { command: "getStat" };
            ws.send(JSON.stringify(request));
        }

        function updateTopTenUI(entries) {
            let topTenDiv = document.getElementById("topTenList");
            let content = `
                <center>
                    <h3>Top 10 Jogadores</h3>
                    <table style="width: 80%; border-collapse: collapse;">
                        <thead>
                            <tr style="background-color: #333; color: #fff;">
                                <th>#</th>
                                <th>Nome</th>
                                <th style="text-align: right;">Placar</th>
                                <th style="text-align: right;">Restantes</th>
                                <th>Data</th>
                            </tr>
                        </thead>
                        <tbody>
            `;
        
            entries.forEach((entry, index) => {
                let bgColor = (index % 2 === 0) ? "#f2f2f2" : "#ffffff";  // Cor alternada para linhas pares/ímpares
                content += `
                    <tr style="background-color: ${bgColor};">
                        <td>${index + 1}</td>
                        <td>${entry.name}</td>
                        <td style="text-align: right;">${entry.score}</td>
                        <td style="text-align: right;">${entry.remaining}</td>
                        <td>${entry.timestamp}</td>
                    </tr>
                `;
            });
        
            content += `
                        </tbody>
                    </table>
                </center>
            `;
            topTenDiv.innerHTML = content;
        }
        
        function updateStatUI(stats) {
            let statDiv = document.getElementById("statList");
            statDiv.innerHTML = `
                <center>
                    <h3>Estatísticas para ${stats.totalGames} Jogos</h3>
                    <table style="width: 60%; border-collapse: collapse;">
                        <thead>
                            <tr style="background-color: #333; color: #fff;">
                                <th>Variável</th>
                                <th style="text-align: right;">Mínimo</th>
                                <th style="text-align: right;">Média</th>
                                <th style="text-align: right;">Máximo</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr style="background-color: #f2f2f2;">
                                <td>Placar</td>
                                <td style="text-align: right;">${stats.minScore}</td>
                                <td style="text-align: right;">${Math.round(stats.avgScore)}</td>
                                <td style="text-align: right;">${stats.maxScore}</td>
                            </tr>
                            <tr style="background-color: #ffffff;">
                                <td>Restantes</td>
                                <td style="text-align: right;">${stats.minRemaining}</td>
                                <td style="text-align: right;">${Math.round(stats.avgRemaining)}</td>
                                <td style="text-align: right;">${stats.maxRemaining}</td>
                            </tr>
                        </tbody>
                    </table>
                </center>
            `;
        }

        function refreshStatsIfVisible() {
            const statDiv = document.getElementById("statList");
        
            if (statDiv && statDiv.style.display === "block") {
                // Se o Stat está visível, solicita o Stat atualizado
                requestStat();
            }
        }

        function refreshTopTenIfVisible() {
            const topTenDiv = document.getElementById("topTenList");
        
            // Verifica se os elementos existem antes de tentar acessá-los
            if (topTenDiv && topTenDiv.style.display === "block") {
                // Se o TopTen está visível, solicita o TopTen atualizado
                requestTopTen();
            }
        }        
        
        function getFormattedTimestamp() {
            const date = new Date();
            const day = String(date.getDate()).padStart(2, '0');
            const month = String(date.getMonth() + 1).padStart(2, '0'); // Mês é 0-indexado
            const year = date.getFullYear();
            const hours = String(date.getHours()).padStart(2, '0');
            const minutes = String(date.getMinutes()).padStart(2, '0');
            const seconds = String(date.getSeconds()).padStart(2, '0');
        
            return `${day}/${month}/${year} ${hours}:${minutes}:${seconds}`;
        }

        function showError(message) {
            alert(message);
        }

        window.addEventListener("unload", function() {
            if (ws) {
                ws.close();
            }
        });        
         
    </script>

</body>
</html>

)rawliteral";

//-------------------------------------
// Rotina de Inicialização da Aplicação
//-------------------------------------

void setup() 
{
   // Inicializa a Serial
    
   Serial.begin(115200);
   while (!Serial) ;

   // Define o CALLBACK do modo CONFIG com alteração
  
   wm.setSaveConfigCallback(saveConfigCallback);
 
   // Define o CALLBACK do modo CONFIG
  
   wm.setAPCallback(configModeCallback);
 
   // Adiciona os campos de parâmetros no MENU do WifiManager

   wm.addParameter(&custom_dnsname);
   wm.addParameter(&custom_ntpserver);
   wm.addParameter(&custom_timezone); 
   wm.addParameter(&custom_user_ota);    
   wm.addParameter(&custom_pass_ota); 
   wm.addParameter(&custom_autoreboot_ota);

   // Define o handle para tratar os eventos do Wifi

   WiFi.onEvent(WiFiEvent);   
   
   // Define o Led que será ligado pela rotina de eventos quando 
   // conectado no WiFi ou desligado quando fora 

   pinMode(pinWIFI,OUTPUT);  

   // Inicializa o Botão interno do ESP32

   pinMode(Boot_Pin, INPUT_PULLUP);

   // Configura a interrupção para detectar a borda de descida do botão Boot

   attachInterrupt(digitalPinToInterrupt(Boot_Pin), buttonISR, FALLING); 
  
   // Defina a porta do WiFiManager para 8080 no modo AP para não conflitar com a
   // porta 80 que vamos utilizar para responder as requisições
  
   wm.setHttpPort(8080);
  
   // Chama Wifi_Manager para conectar no Wifi ou entrar em modo de configuração
   // caso os parãmetros SSID, Senha, CPIID e Intervalo do TIMER não estejam persistidos

   Check_WiFiManager(!wm.getWiFiIsSaved());

   // Verifica se teve está conectado na Internet

   if (WiFi.status() == WL_CONNECTED)
   { 
      // Se chegamos até aqui é porque estamos conectados
  
      Serial.println("WiFi conectado...");
      Serial.print("IP address: ");
      Serial.println(WiFi.localIP());
  
      // Imprime o MAC
  
      Serial.print("MAC: ");
      Serial.println(WiFi.macAddress());
  
      // Imprime o Sinal Wifi
  
      Serial.print("Sinal: ");
      Serial.print(WiFi.RSSI());
      Serial.println(" db");  
  
      // Verifica se está navegando pela internet pois às vezes fica conectado no AP porém sem internet
  
      lastInternet = Ping.ping(ip,4);
      if (!lastInternet)
      {
         semInternet = millis();
         Serial.println("Sem internet no momento...");
      }
      else 
      {
         Serial.print("Internet ativa com média de ");
         Serial.print(Ping.averageTime());
         Serial.println(" ms");
      }
  
      // Sincroniza o horário interno com o Servidor NTP nacional
  
      Serial.print("Tentando sincronismo com o servidor NTP ");
      Serial.print(NTP_SERVER);
      Serial.print(" com TimeZone ");
      Serial.println(TZ_INFO);
  
      configTime(0, 0, NTP_SERVER);
      setenv("TZ", TZ_INFO, 1);
      tzset();
  
      if (getNTPtime(10))
      { // wait up to 10sec to sync
        Serial.println("NTP Server sincronizado");
      } 
      else 
      {
         Serial.println("\nTimer interno não foi sincronizado");
         //ESP.restart();
      }

      // Define o HostName para o servidor web para facilitar o acesso na rede local
      // sem conhecer o IP previamente
  
      Serial.print("Adicionando " + String(DNS_NAME) + " no MDNS... ");
      if (setDNSNAME(DNS_NAME))
      {
          Serial.println("adicionado corretamente no MDNS!");
      }
      else 
      {
         Serial.println("Erro ao adicionar no MDNS!");
      }           

   }

   // Inicializa o SPIFFS
    
   if (!SPIFFS.begin(true)) {
      Serial.println("Erro ao iniciar SPIFFS");
      return;
   }

   // Situações especiais

   //deleteFile(JSON_TOPTEN_FILE);  // Apaga o arquivo de TopTen do SPIFFS
   //deleteFile(JSON_STAT_FILE);    // Deleta o arquivo de estatísticas
   //migrateTopTenTimestamps();     // Corrige timestamps antigos, se necessário

   // Carga dos TopTen e Estatísticas

   loadTopTen();  // Carrega TopTen do SPIFFS
   loadStats();   // Carrega as estatísticas do SPIFFS   

   // Inicializando WebSocket e servidor
   
   ws.onEvent(onWsEvent);
   server.addHandler(&ws);

  // Credenciais para atualizações via OTA
   
   ElegantOTA.setAuth(user_OTA,pass_OTA);

   // Habilita/Desabilita AutoRebbot após a atualização
   
   ElegantOTA.setAutoReboot(autoRebootOTA);   

   // Inicia o OTA para atualização via Web

   ElegantOTA.begin(&server);       

   server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) 
   {
      request->send(200, "text/html", expandeHtml(htmlContent));
   });

   // Inicia o servidor propriamente dito

   server.begin();

   // Monta o SSID do modo AP para permitir a configuração
  
   sprintf(ssid_config, "ESP32_%X",(uint32_t)ESP.getEfuseMac());      

  // Mostra Informações do Startup na Console
   
   Serial.printf("Horário Local do Startup: %s\n",getTimeStamp().c_str());
   Serial.printf("Servidor iniciado no IP %s\n",WiFi.localIP().toString());

  // Mostra as configurações da Placa

#if defined(CONFIG_IDF_TARGET_ESP32C3)       // ESP32C3 Mini
   Serial.println("Placa ESP32C3");    
#elif defined(CONFIG_IDF_TARGET_ESP32S3)     // XIAO ESP32S3
  Serial.println("Placa ESP32S3");   
#elif defined(ESP32)                         // ESP32 30 pinos
  Serial.println("Placa ESP32");
#endif

  Serial.printf("pinWIFI: %d\n",pinWIFI);
  Serial.printf("Led_Wifi_ON: %d\n",Led_Wifi_ON);
  Serial.printf("Led_Wifi_OFF: %d\n",Led_Wifi_OFF);
  Serial.printf("Boot_Pin: %d\n",Boot_Pin);    

  // Mostra a configuração de DEBUG

#ifdef DEBUG   
   Serial.println("Modo Debug ativado...");
   Serial.printf("Tamanho de uma Cell: %d bytes\n", sizeof(Cell));
   Serial.printf("Tamanho de um Board: %d bytes\n", sizeof(BoardState));
#else
   Serial.println("Modo Debug desativado...");
#endif

   // Mostra a Memória RAM Disponível
   
   printFreeRAM("Inicialização");
   
}

//----------------------------
// Loop Principal da Aplicação
//----------------------------

void loop() 
{
   //----------------------------------------------------------
   // Função 1 : Limpa as conexões websocket perdidas por algum 
   //            motivo (ex: Navegador fechado)
   //-----------------------------------------------------------
   
   ws.cleanupClients();

   //--------------------------------------------------------------
   // Função 2 : Verifica se está sem internet tentar a reconexão 
   //--------------------------------------------------------------   

   if (WiFi.status() != WL_CONNECTED) 
   {
      // Verifica se deve tentar a reconexão da Internet

      if (millis()-lastReconexao > TIMERECONEXAO)
      {
         wm.autoConnect();
         lastReconexao = millis();
      }
   }

   //--------------------------------------------------------------------------------------------------
   // Bloco 3 : Verifica se o botão de BOOT foi apertado para forçar a entrada no modo de configuração. 
   //           É útil quando a senha do wifi mudou ou está se conectando em outra rede wifi. Isso 
   //           evita ter o SSID/senha no código, a recompilação e upload do código no ESP32.
   //--------------------------------------------------------------------------------------------------

   if (buttonState)
   {
      // Reseta o estado do botão

      buttonState = false;

      // Força a entrada em modo de configuração

      wm.resetSettings();   
      ESP.restart();
       
   }

   //--------------------------------------------------------------
   // Função 4 : checa o OTA para saber se há atualização
   //--------------------------------------------------------------  

   ElegantOTA.loop();   
   
}

//------------------------------------------------
// Evento chamado no processo de conexão do Wifi
//------------------------------------------------

void WiFiEvent(WiFiEvent_t event)
{
  Serial.printf("[Evento Wi-Fi] evento: %d\n", event);

  switch (event)
  {
    case SYSTEM_EVENT_WIFI_READY:
      Serial.println("interface WiFi pronta");
      break;
    case SYSTEM_EVENT_SCAN_DONE:
      Serial.println("Pesquisa por AP completada");
      break;
    case SYSTEM_EVENT_STA_START:
      Serial.println("Cliente WiFi iniciado");
      break;
    case SYSTEM_EVENT_STA_STOP:
      Serial.println("Clientes WiFi  cancelados");
      break;
    case SYSTEM_EVENT_STA_CONNECTED:
      Serial.println("Conectado ao AP");
      digitalWrite(pinWIFI,Led_Wifi_ON);   // Liga o LED 
      break;
    case SYSTEM_EVENT_STA_DISCONNECTED:
      Serial.println("Desconectado do AP WiFi");
       digitalWrite(pinWIFI,Led_Wifi_OFF); // Desliga o LED 
      //Check_WiFiManager(false);
      break;
    case SYSTEM_EVENT_STA_AUTHMODE_CHANGE:
      Serial.println("Modo de Autenticação do AP mudou");
      break;
    case SYSTEM_EVENT_STA_GOT_IP:
      Serial.print("Endereço IP obtido: ");
      Serial.println(WiFi.localIP());
      break;
    case SYSTEM_EVENT_STA_LOST_IP:
      Serial.println("Endereço IP perdido e foi resetado para 0");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_SUCCESS:
      Serial.println("WPS: modo enrollee bem sucedido");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_FAILED:
      Serial.println("WPS: modo enrollee falhou");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_TIMEOUT:
      Serial.println("WPS: timeout no modo enrollee");
      break;
    case SYSTEM_EVENT_STA_WPS_ER_PIN:
      Serial.println("WPS: pin code no modo enrollee");
      break;
    case SYSTEM_EVENT_AP_START:
      Serial.println("AP Wifi Iniciado");
      break;
    case SYSTEM_EVENT_AP_STOP:
      Serial.println("AP Wifi parado");
      break;
    case SYSTEM_EVENT_AP_STACONNECTED:
      Serial.println("Cliente conectado");
      break;
    case SYSTEM_EVENT_AP_STADISCONNECTED:
      Serial.println("Cliente desconectado");
      break;
    case SYSTEM_EVENT_AP_STAIPASSIGNED:
      Serial.println("IP associado ao Cliente");
      break;
    case SYSTEM_EVENT_AP_PROBEREQRECVED:
      Serial.println("Requisição de probe recebida");
      break;
    case SYSTEM_EVENT_GOT_IP6:
      Serial.println("IPv6 é preferencial");
      break;
    case SYSTEM_EVENT_ETH_START:
      Serial.println("Interface Ethernet iniciada");
      break;
    case SYSTEM_EVENT_ETH_STOP:
      Serial.println("Interface Ethernet parada");
      break;
    case SYSTEM_EVENT_ETH_CONNECTED:
      Serial.println("Interface Ethernet conectada");
      break;
    case SYSTEM_EVENT_ETH_DISCONNECTED:
      Serial.println("Interface Ethernet desconectada");
      break;
    case SYSTEM_EVENT_ETH_GOT_IP:
      Serial.println("Endereço IP obtido");
      break;
    default: break;
  }
}

//-------------------------------------------------
// Expande o HTML à minha maneira pois o pré-
// processador do C++ usa % como delimitor e no
// HTML já outras ocorrência de % que gerariam erro 
//-------------------------------------------------

String expandeHtml(String html)
{
  html.replace("%iplocal%",WiFi.localIP().toString());
  return html;
}

//------------------------------
// Trata os eventos do WebSocket
//------------------------------

void onWsEvent(AsyncWebSocket* server, AsyncWebSocketClient* client, AwsEventType type, void* arg, uint8_t* data, size_t len) 
{
    uint32_t clientId = client->id();

    if (type == WS_EVT_CONNECT) {
        Serial.printf("Cliente conectado: %u\n", clientId);
    } 
    else if (type == WS_EVT_DISCONNECT) {
        Serial.printf("Cliente desconectado: %u\n", clientId);

        // Encerra e remove a sessão de jogo do cliente
        if (gameSessions.count(clientId) > 0) {
            delete gameSessions[clientId];
            gameSessions.erase(clientId);
        }
    }   
    else if (type == WS_EVT_DATA) {
        DynamicJsonDocument doc(1024);
        deserializeJson(doc, data, len);

        String command = doc["command"];
        //Serial.printf("Comando %s recebido...\n",command.c_str());
        
        // Controle de início de jogo com limite de jogos ativos

        if (command == "getTopTen") 
        {
            sendTopTen(client);
        }
        else if (command == "getStat") 
        {
            sendStats(client);
        }  
        else if (command == "addTopTen") 
        {
            String name = doc["name"];
            int score = doc["score"];
            int remaining = doc["remaining"];
            String timestamp = doc["timestamp"];
            addTopTen(name, score, remaining, timestamp);  // Adiciona ao TopTen
        }               
        else if (command == "start") 
        {
            // Verifica se há espaço para mais um jogo
            
            if (gameSessions.size() < MAX_GAMES) 
            {  
                if (gameSessions.count(clientId) == 0) 
                {
                    JawBreakerGame* newGame = new JawBreakerGame(client, &ws);
                    if (newGame == nullptr) {
                        Serial.println("Erro: Falha ao alocar memória para o novo jogo.");
                        return;
                    }  
                    gameSessions[clientId] = newGame;    
                    printFreeRAM("Novo Jogo Conexão " + String(clientId));            
                }
                gameSessions[clientId]->initializeBoard();
                gameSessions[clientId]->sendBoard();
                gameSessions[clientId]->sendTotalScore();
                gameSessions[clientId]->sendScore(0);

                DynamicJsonDocument response(128);
                response["command"] = "gameStatus";
                response["status"] = "started";
 
                String output;
                serializeJson(response, output);
                client->text(output);                
            } 
            else 
            {
                // Envia uma mensagem ao cliente informando que o número máximo de jogos foi atingido
                DynamicJsonDocument response(128);
                response["command"] = "gameStatus";
                response["status"]  = "occupied";

                String output;
                serializeJson(response, output);
                client->text(output);
            }
        } 
        else if (gameSessions.count(clientId) > 0) 
        {
            // Obtém a instância do jogo associada ao cliente
            JawBreakerGame* game = gameSessions[clientId];

            if (command == "end") 
            {
                printFreeRAM("Fim de Jogo Conexão "+String(clientId));
                delete gameSessions[clientId];
                gameSessions.erase(clientId);
            } 
            else if (command == "select") 
            {
                int row = doc["row"];
                int col = doc["col"];
                //Serial.printf("row=%d col=%d chamando Clear...\n",row,col);
                game->selectedRegionClear();
                //Serial.println("Chamando selecionarAdjacentes...");
                game->iniciarSelecionarAdjacentes(row, col);
                //Serial.println("Chamando sendSelection...");
                game->sendSelection();  // Envia a seleção para o cliente
            } 
            else if (command == "remove") 
            {
                bool gameOver = game->removeSelectedCells();
                game->sendBoard();       
                game->sendTotalScore();  
                game->sendScore(0);      

                if (gameOver) 
                {
                    int restantes = game->remaining();
                    bool prompt = isTopTenEligible(game->getTotalScore(), restantes);  // Verifica elegibilidade para TopTen
                    game->sendGameOver(prompt);
                    updateStats(game->getTotalScore(), restantes);  // Atualiza Stat
                }
            }
            else if (command == "undo") 
            {
                game->undo();
            }
            else if (command == "redo") 
            {
                game->redo();
            }
        }
    }
}

//--------------------------------------------------
// Função para monitorar o uso de memória disponível
//--------------------------------------------------

void printFreeRAM(String context) 
{
    size_t freeHeap = esp_get_free_heap_size();
    Serial.printf("RAM disponível (%s): %u bytes\n", context.c_str(), freeHeap);
}

//---------------------------
// Adiciona o TopTen na Lista
//---------------------------

void addTopTen(String name, int score, int remaining, String timestamp) 
{
    showConsole("addTopTen");

    // Entra na Região Crítica : somente um cliente pode passar aqui num determinado momento
    
    portENTER_CRITICAL(&toptenMux);
 
    TopTenEntry entry = {name, score, remaining, timestamp};
    topTen.push_back(entry);

    std::sort(topTen.begin(), topTen.end(), [](const TopTenEntry& a, const TopTenEntry& b) {
        return a.score > b.score;
    });

    if (topTen.size() > 10) {
        topTen.pop_back();
    }

    // Sai da Região Crítica : liberando o acesso aos demais 
  
    portEXIT_CRITICAL(&toptenMux);    

    // Salva no SPIFFS

    saveTopTen();  

    // Notifica a todos os clientes que Topten foi alterado

    DynamicJsonDocument doc(256);
    doc["command"] = "refreshTopTen";

    String output;
    serializeJson(doc, output);
    ws.textAll(output);  // Envia a mensagem para todos os clientes conectados    
}

//-----------------------------------
// Salva a lista dos TopTen no SPIFFS
//-----------------------------------

void saveTopTen() {
    showConsole("saveTopTen");
    File file = SPIFFS.open(JSON_TOPTEN_FILE, "w");
    if (!file) {
        Serial.println("Erro ao abrir TopTen para gravação.");
        return;
    }

    DynamicJsonDocument doc(1024);
    JsonArray array = doc.to<JsonArray>();

    for (const auto& entry : topTen) {
        JsonObject obj = array.createNestedObject();
        obj["name"] = entry.name;
        obj["score"] = entry.score;
        obj["remaining"] = entry.remaining;
        obj["timestamp"] = entry.timestamp;
    }

    serializeJson(doc, file);
    file.close();
}

//--------------------------------------
// Recupera a lista dos TopTen do SPIFFS
//--------------------------------------

void loadTopTen() {
    showConsole("loadTopTen");
    File file = SPIFFS.open(JSON_TOPTEN_FILE, "r");
    if (!file) {
        Serial.println("Arquivo TopTen não encontrado.");
        return;
    }

    DynamicJsonDocument doc(1024);
    DeserializationError error = deserializeJson(doc, file);
    if (!error) 
    {
       Serial.println("TopTen recuperado do SPIFFS...");
       serializeJsonPretty(doc, Serial);  
       Serial.println();  
       topTen.clear();
       for (JsonObject entry : doc.as<JsonArray>()) 
       {
          topTen.push_back({
             entry["name"].as<String>(),
             entry["score"],
             entry["remaining"],
             entry["timestamp"]
          });
       }
    }
    else Serial.println("Falha ao ler TopTen.");
    file.close();
 }

//----------------------------------------------------
// Verifica se o resultado do Jogador entra nos Topten
//----------------------------------------------------

bool isTopTenEligible(int score, int remaining) 
{
#ifdef DEBUG  
    Serial.println("From isTopTenEligible");
#endif    

    // Se o vetor topTen estiver vazio, qualquer placar é elegível
    if (topTen.empty()) {
        return true;
    }

    // Se o vetor topTen já tiver 10 entradas, verifica o menor placar
    if (topTen.size() >= 10) {
        int minScore = topTen.back().score;
        int minRemaining = topTen.back().remaining;

        // Verifica a elegibilidade com base nas novas condições
        if (score > minScore) {
            return true;
        } else if (score == minScore && remaining < minRemaining) {
            return true;
        } else {
            return false;
        }
    }
    // Caso contrário, há menos de 10 entradas, então é elegível
    return true;
}

//------------------------------------
// Envia a lista de TopTen para o HTLM
//------------------------------------

void sendTopTen(AsyncWebSocketClient* client) {
    showConsole("sendTopTen");
    DynamicJsonDocument doc(1024);
    doc["command"] = "topTenData";

    JsonArray entries = doc.createNestedArray("entries");
    for (const auto& entry : topTen) {
        JsonObject obj = entries.createNestedObject();
        obj["name"] = entry.name;
        obj["score"] = entry.score;
        obj["remaining"] = entry.remaining;
        obj["timestamp"] = entry.timestamp;
    }

    String output;
    serializeJson(doc, output);
    client->text(output);
}

//-----------------------------------------------------
// Atualiza o resultado do Jogo nas Estatístcas Globais
//-----------------------------------------------------

void updateStats(int score, int remaining) 
{
    showConsole("updateStats");

    // Entra na Região Crítica : somente um cliente pode passar aqui num determinado momento
    
    portENTER_CRITICAL(&statsMux);
    
    stats.minScore = min(stats.minScore, score);
    stats.maxScore = max(stats.maxScore, score);
    stats.avgScore = (stats.avgScore * stats.totalGames + score) / (stats.totalGames + 1);

    stats.minRemaining = min(stats.minRemaining, remaining);
    stats.maxRemaining = max(stats.maxRemaining, remaining);
    stats.avgRemaining = (stats.avgRemaining * stats.totalGames + remaining) / (stats.totalGames + 1);

    stats.totalGames++;

    // Sai da Região Crítica : liberando o acesso aos demais 
  
    portEXIT_CRITICAL(&statsMux);    

    // Persiste no SPIFFS
    
    saveStats();  

    // Notifica a todos os clientes que as Estatísticas foram alteradas

    DynamicJsonDocument doc(256);
    doc["command"] = "refreshStats";

    String output;
    serializeJson(doc, output);
    ws.textAll(output);  // Envia a mensagem para todos os clientes conectados
    
}

//----------------------------------------
// Salva as Estatísticas Globais no SPIFFS
//----------------------------------------

void saveStats() {
    showConsole("saveStats");
    File file = SPIFFS.open(JSON_STAT_FILE, "w");
    if (!file) {
        Serial.println("Erro ao abrir Estatísticas para gravação.");
        return;
    }

    DynamicJsonDocument doc(512);

    doc["minScore"] = stats.minScore;
    doc["maxScore"] = stats.maxScore;
    doc["avgScore"] = stats.avgScore;
    doc["minRemaining"] = stats.minRemaining;
    doc["maxRemaining"] = stats.maxRemaining;
    doc["avgRemaining"] = stats.avgRemaining;
    doc["totalGames"]   = stats.totalGames;  // Adiciona o total de jogos

    serializeJson(doc, file);
    file.close();
}

//------------------------------------------
// Recupera as Esatísticas Globais do SPIFFS
//------------------------------------------

void loadStats() {
    showConsole("loadStats");
    // Inicializa cada campo manualmente
    stats.minScore = INT_MAX;
    stats.maxScore = 0;
    stats.avgScore = 0.0;
    stats.minRemaining = INT_MAX;
    stats.maxRemaining = 0;
    stats.avgRemaining = 0.0;
    stats.totalGames = 0;

    if (SPIFFS.exists(JSON_STAT_FILE)) {
        File file = SPIFFS.open(JSON_STAT_FILE, "r");
        if (file) {
            DynamicJsonDocument doc(512);
            DeserializationError error = deserializeJson(doc, file);
            if (!error) {
                Serial.println("Stats recuperado do SPIFFS...");
                serializeJsonPretty(doc, Serial);  
                Serial.println();                
                stats.minScore = doc["minScore"];
                stats.maxScore = doc["maxScore"];
                stats.avgScore = doc["avgScore"];
                stats.minRemaining = doc["minRemaining"];
                stats.maxRemaining = doc["maxRemaining"];
                stats.avgRemaining = doc["avgRemaining"];
                stats.totalGames = doc["totalGames"];
            }
            else Serial.println("Falha ao ler Stats do SPIFFS.");
            file.close();
        }
    }
}

//------------------------------------------
// Envia as Estatísticas Globais para o HTLM
//------------------------------------------

void sendStats(AsyncWebSocketClient* client) {
    showConsole("sendStats");
    DynamicJsonDocument doc(1024);

    doc["command"] = "statData";  // Adiciona o comando
    doc["stats"]["minScore"] = stats.minScore;
    doc["stats"]["maxScore"] = stats.maxScore;
    doc["stats"]["avgScore"] = stats.avgScore;
    doc["stats"]["minRemaining"] = stats.minRemaining;
    doc["stats"]["maxRemaining"] = stats.maxRemaining;
    doc["stats"]["avgRemaining"] = stats.avgRemaining;
    doc["stats"]["totalGames"] = stats.totalGames;

    String jsonString;
    serializeJson(doc, jsonString);

    client->text(jsonString);  // Envia para o cliente WebSocket
}

//--------------------------------------------------
// Mostra um Contexto genérico na Console para DEBUG
//--------------------------------------------------

void showConsole(String contexto)
{
#ifdef DEBUG  
   Serial.printf("From %s\n",contexto.c_str());
#endif
}

//------------------------------------------------
// Devolve o localtime dd/mm/aaaa hh:mm:ss
//------------------------------------------------

String getTimeStamp()
{
  time_t now;
  time(&now);
  char timestamp[30];
  strftime(timestamp, 30, "%d/%m/%Y %T", localtime(&now));
  return String(timestamp);
}

//---------------------------------------------------------
// Sincroniza o horário do ESP32 com  NTP server brasileiro
//---------------------------------------------------------

bool getNTPtime(int sec) 
{

  {
    uint32_t start = millis();

    tm timeinfo;
    time_t now;
    int cont=0;

    do 
    {
      time(&now);
      localtime_r(&now, &timeinfo);
      if (++cont % 80 == 0) Serial.println();
      else Serial.print(".");
      delay(10);
    } while (((millis() - start) <= (1000 * sec)) && (timeinfo.tm_year < (2016 - 1900)));
    if (timeinfo.tm_year <= (2016 - 1900)) return false;  // the NTP call was not successful
    Serial.print("\nnow ");
    Serial.println(now);
    Serial.print("Time "); 
    Serial.println(getTimeStamp());
  }

  return true;
}

//--------------------------------------------------
// Rotina de Tratamento da Interrupção do Botão Boot
//--------------------------------------------------

void buttonISR() 
{
   buttonState = true;
}

//-------------------------------------------------------
// Define o HostName como DNS NAME
//-------------------------------------------------------

bool setDNSNAME(String nome)
{
   WiFi.setHostname(nome.c_str());
   bool ok = MDNS.begin(nome.c_str());
   if (ok) 
   {
      MDNS.addService("http", "tcp", 80);
      MDNS.setInstanceName(nome.c_str()); // Adicionar o nome da instância      
   }
   return ok;
}

//------------------------------------------------
// Persiste NTP Server, Timezone e OTA no SPIFFS
//------------------------------------------------

void saveConfigFile()
// O arquivo de Config é salvo no formato JSON
{
  Serial.println(F("Persistindo a configuração..."));
  
  // Atualiza a base de software e parâmetros gerais

  dbParm["DnsName"] = DNS_NAME;  
  dbParm["NTPServer"] = NTP_SERVER;
  dbParm["Timezone"] = TZ_INFO;
  dbParm["usuarioOTA"] = user_OTA;
  dbParm["senhaOTA"] = pass_OTA;
  dbParm["autorebootOTA"] = autoRebootOTA;

  // Abre o arquivo de configuração
  File configFile = SPIFFS.open(JSON_CONFIG_FILE, "w");
  if (!configFile)
  {
    // Erro, arquino não foi aberto
    Serial.println("Erro ao persistir a configuração");
  }
 
  // Serializa os dados do JSON no arquivo
  serializeJsonPretty(dbParm, Serial);
  Serial.println();
  if (serializeJson(dbParm, configFile) == 0)
  {
    // Erro ai gravar o arquivo
    Serial.println(F("Erro ao gravar o arquivo de configuração"));
  }
  // Fecha o Arquivo
  configFile.close();
}

//------------------------------------------------
// Recupera NTP Server, Timezone e OTA do SPIFFS
//------------------------------------------------
 
bool loadConfigFile()
// Carrega o arquivo de Configuração
{

  // Verifica se o SPIFFS já foi inicializado
  
  if (!SPIFFS.begin(true))
  {
     SPIFFS.format();
     Serial.println("Sistema de Arquivo no SPIFFS foi formatado");
  }
 
  // Lê as configurações no formato JSON
  Serial.println("Montando o FileSystem...");
 
  // Força a entrada na primeira vez
  if (SPIFFS.begin(true))
  {
    Serial.println("FileSystem montado...");
    
    //Serial.println("Removendo o arquivo de configuração...");
    //SPIFFS.remove(JSON_CONFIG_FILE);
    
    if (SPIFFS.exists(JSON_CONFIG_FILE))
    {
      // o arquivo existe, vamos ler
      Serial.println("Lendo o arquivo de configuração");
      File configFile = SPIFFS.open(JSON_CONFIG_FILE, "r");
      if (configFile)
      {
        Serial.println("Arquivo de configuração aberto...");
        DeserializationError error = deserializeJson(dbParm, configFile);
        
        if (!error)
        {
          Serial.println("JSON do SPIFFS recuperado...");
          serializeJsonPretty(dbParm, Serial);
          Serial.println();          

          if (dbParm.containsKey("DnsName")) strcpy(DNS_NAME, dbParm["DnsName"]);
          else strcpy(DNS_NAME, DEFAULT_DNS_NAME);          
 
          if (dbParm.containsKey("NTPServer")) strcpy(NTP_SERVER, dbParm["NTPServer"]);
          else strcpy(NTP_SERVER, DEFAULT_NTP_SERVER);
          
          if (dbParm.containsKey("Timezone")) strcpy(TZ_INFO, dbParm["Timezone"]);
          else strcpy(TZ_INFO, DEFAULT_TZ_INFO);
          
          if (dbParm.containsKey("usuarioOTA")) strcpy(user_OTA, dbParm["usuarioOTA"]);
          else strcpy(user_OTA, USER_UPDATE);
          
          if (dbParm.containsKey("senhaOTA")) strcpy(pass_OTA, dbParm["senhaOTA"]);
          else strcpy(pass_OTA, PASS_UPDATE);

          if (dbParm.containsKey("autorebootOTA")) 
          {
             autoRebootOTA = dbParm["autorebootOTA"];
             if (autoRebootOTA) strcpy(val_autoreboot,"1");
             else strcpy(val_autoreboot,"0"); 
          }
          else 
          {
             autoRebootOTA = true;
             strcpy(val_autoreboot,"1");     
          }

          return true;
        }
        else
        {
          // Erro ao ler o JSON
          Serial.println("Erro ao carregar o JSON da configuração...");
        }
      }
    }
    else
    {
       // Monta base default   

       DeserializationError error = deserializeJson(dbParm, dbDefault);
    
       // Verificar se há erro no parsing
       
       if (!error)
       {
          Serial.println("JSON default recuperado...");
          serializeJsonPretty(dbParm, Serial);
          Serial.println();                 

          strcpy(DNS_NAME, dbParm["DnsName"]);
          strcpy(NTP_SERVER, dbParm["NTPServer"]);
          strcpy(TZ_INFO, dbParm["Timezone"]);
          strcpy(user_OTA, dbParm["usuarioOTA"]);
          strcpy(pass_OTA, dbParm["senhaOTA"]);
          autoRebootOTA = dbParm["autorebootOTA"];
          if (autoRebootOTA) strcpy(val_autoreboot,"1");
          else strcpy(val_autoreboot,"0"); 

          // Salva o default no SPIFFS

          saveConfigFile();

          return true;
       }
       else
       {
          // Erro ao ler o JSON
          Serial.println("Erro ao carregar o JSON da configuração...");
       }
    }
  }
  else
  {
    // Erro ao montar o FileSystem
    Serial.println("Erro ao montar o FileSystem");
  }
 
  return false;
}

//----------------------------------------------------
// Inicialização/Configuração do WiFi Manager no ESP32
//----------------------------------------------------

void Check_WiFiManager(bool forceConfig)
{

   // Tenta carregar os parâmetros do SPIFFS
  
   bool spiffsSetup = loadConfigFile();
   if (!spiffsSetup)
   {
      Serial.println(F("Forçando o modo de configuração..."));
      forceConfig = true;
   }

   // Copia os campos para o FORM do WifiManager

   custom_dnsname.setValue(DNS_NAME, MAX_EDIT_LEN+1);
   custom_ntpserver.setValue(NTP_SERVER, MAX_EDIT_LEN+1);
   custom_timezone.setValue(TZ_INFO, MAX_EDIT_LEN+1); 
   custom_user_ota.setValue(user_OTA, MAX_EDIT_LEN+1); 
   custom_pass_ota.setValue(pass_OTA, MAX_EDIT_LEN+1);
   custom_autoreboot_ota.setValue(val_autoreboot,sizeof(val_autoreboot));

   if (forceConfig) 
   { 
      // reseta configurações
      
      wm.resetSettings();   

      // Define o modo AP
  
      WiFi.mode(WIFI_STA);

      // Entra no modo de AP de configuração ... com senha fixa
     
      if (!wm.startConfigPortal(ssid_config, pass_config))
      {
         Serial.println("Erro na conexão com timeout no modo AP...");
         //setStateWifiEEPROM(true);
      }
      //else setStateWifiEEPROM(false);
    
   }
   else
   {
      // Entra no modo de conexão normal recuperando o SSID/Senha anteriores
      if (!wm.autoConnect())
      {
         Serial.println("Erro na conexão com timeout...");
      }
      //setStateWifiEEPROM(false);
   }

   // Recupera o campo DNSNAME preenchido na interface do WifiManager
  
   strncpy(DNS_NAME, custom_dnsname.getValue(), sizeof(DNS_NAME));
   Serial.print("DNSNAME:");
   Serial.println(DNS_NAME);

   // Recupera o campo NTP SERVER preenchido na interface do WifiManager
  
   strncpy(NTP_SERVER, custom_ntpserver.getValue(), sizeof(NTP_SERVER));
   Serial.print("NTP_SERVER:");
   Serial.println(NTP_SERVER);
 
   // Recupera o campo intervaloTimer do WifiManager preenchido na interface convertendo para inteiro

   strncpy(TZ_INFO, custom_timezone.getValue(), sizeof(TZ_INFO));
   Serial.print("TZ_INFO: ");
   Serial.println(TZ_INFO);

   // Recupera o campo usuário da Atualização do WifiManager

   strncpy(user_OTA, custom_user_ota.getValue(), sizeof(user_OTA));
   Serial.print("User_OTA: ");
   Serial.println(user_OTA);

   // Recupera o campo senha da Atualização do WifiManager

   strncpy(pass_OTA, custom_pass_ota.getValue(), sizeof(pass_OTA));
   Serial.print("Pass_OTA: ");
   Serial.println(pass_OTA);

   // Recupera o campo AutoReboot da Atualização do WifiManager

   strncpy(val_autoreboot, custom_autoreboot_ota.getValue(), sizeof(val_autoreboot));
   Serial.print("AutoReboot_OTA: ");
   Serial.println(val_autoreboot);   
   autoRebootOTA = (strcmp(val_autoreboot, "1") == 0) ? true : false;

   // Salva os parâmetros no FileSystem FLASH -> não perde quando desligado
  
   if (shouldSaveConfig)
   {
      saveConfigFile();
   } 
  
}
 
//----------------------------------------------------------
// Callback para informação do processo de configuração WiFi
//----------------------------------------------------------
 
void saveConfigCallback()
// Callback para nos lembrar de salvar o arquivo de configuração
{
  Serial.println("Persistência necessária...");
  shouldSaveConfig = true;
}

//----------------------------------------------------------
// Callback para WifiManager 
//----------------------------------------------------------
 
void configModeCallback(WiFiManager *myWiFiManager)
// É chamado no modo de configuração
{
  Serial.println("Entrando no modo de configuração...");

  Serial.print("Config SSID: ");
  Serial.println(myWiFiManager->getConfigPortalSSID());
 
  Serial.print("Config IP Address: ");
  Serial.println(WiFi.softAPIP());
}

//----------------------------------------------
// Função para delatar arquivos do SPIFFS
// Útil para resetar estados iniciais do jogo
//----------------------------------------------

void deleteFile(const char* path) 
{
    // Verifica se o arquivo existe
    if (SPIFFS.exists(path)) 
    { 
        if (SPIFFS.remove(path)) 
        {
            Serial.printf("Arquivo %s deletado com sucesso\n", path);
        } 
        else 
        {
            Serial.printf("Falha ao deletar o arquivo %s\n", path);
        }
    } 
    else 
    {
        Serial.printf("Arquivo %s não encontrado\n", path);
    }
}

//----------------------------------------------
// Função que migra os dados antigos no SPIFFS
// Útil quando o layout do JSON dos TopTen mudar
//----------------------------------------------

void migrateTopTenTimestamps() {
    File file = SPIFFS.open(JSON_TOPTEN_FILE, "r");
    if (!file) {
        Serial.println("Erro ao abrir o arquivo TopTen para migração.");
        return;
    }

    // Lê o JSON do arquivo
    DynamicJsonDocument doc(2048);
    DeserializationError error = deserializeJson(doc, file);
    file.close();

    if (error) {
        Serial.println("Erro ao desserializar JSON para migração.");
        return;
    }

    // Itera sobre os registros e corrige timestamps inválidos
    bool migrated = false;

    // Itera sobre os registros e corrige timestamps inválidos
    for (JsonObject entry : doc.as<JsonArray>()) {
        if (!entry.containsKey("timestamp") || entry["timestamp"] == 0) {
            entry["timestamp"] = getTimeStamp();  // Atualiza o timestamp
            migrated = true;
        }
    }

    // Se alguma alteração foi feita, salva o JSON atualizado
    if (migrated) {
        File file = SPIFFS.open(JSON_TOPTEN_FILE, "w");
        if (!file) {
            Serial.println("Erro ao abrir o arquivo TopTen para escrita.");
            return;
        }
        serializeJson(doc, file);
        file.close();
        Serial.println("Migração dos timestamps concluída com sucesso.");
    } else {
        Serial.println("Nenhuma migração necessária.");
    }
}

Uso do WifiManager

A biblioteca WifiManager foi utilizada pela permitir a definição da rede Wifi a ser utilizada pelo próprio usuário evitando assim que o SSID/Senha fiquem internos no código implicando em recompilação do aplicativo toda vez que a rede fosse mudada ou a senha alterada. Além disso, o WifiManager permite que o usuário defina certos parâmetros da aplicação dando maior flexibilidade e liberdade.

Passo a passo de como utilizar:

  • Na primeira execução do programa, o WifiManager não estará configurado e entrará no AP MODE, onde poderá ser visto na lista de AP’s da Rede WiFi (Figura 6 – ESP32 na Lista de AP’s da Rede). Opcionalmente, pressionando-se o botão BOOT do ESP32 no Modo Normal, o modo de configuração também será ativado.
  • Deve-se selecionar o ESP32_XXXX (onde XXXX é o serial do ESP32). Veja a Figura 7 – Conectado no ESP32 AP MODE.
  • Uma vez conectado no ESP32, deve-se acessar a URL http://192.168.4.1:8080 para a tela de entrada do WiFiManager (Figura 8 – Acessando http://192.168.4.1:8080).
  • Uma tela aparecerá com os parâmetros a serem definidos antes de pressionar SAVE. Quando os parâmetros estiverem todos definidos e conferidos (inclusive o SSID da Rede WiFi e a senha), deve-se pressionar o botão SAVE mas abaixo na tela. O WiFiManager salvará os parâmetros no SPIFFS e o ESP32 entrará com a aplicação ativa, conectado na Rede WiFi selecionada e poderá ser acessado via a URL http://jawbreaker.local , supondo que o DNSNAME definido seja jawbreaker.

 

Figura 8 – ESP32 na Lista de AP’s da Rede

Figura 9 – Conectado no ESP32 AP MODE

Figura 10 – Acessando http://192.168.4.1:8080

Figura 11 – Parâmetros a serem definidos


Utilização da ElegantOTA

A biblioteca ElegantOTA foi utilizada para permitir a atualização do aplicativo pela interface Web sem a necessidade de levar o circuito do ESP32 até a estação de compilação. Isso permite, por exemplo, que o desenvolvedor libere uma nova versão em qualquer lugar do mundo e o próprio usuário instale a nova versão dando maior independência.

Figura 12 – Tela da Atualização do Aplicativo

Figura 13 – Tela da Autenticação necessária para atualizar


Conclusão

O desenvolvimento do jogo JawBreaker para o ESP32 com interação via WebSocket e interface no navegador foi uma jornada enriquecedora, misturando desafios de hardware e software. O projeto buscou mostrar como é possível utilizar o poder de processamento do ESP32 para gerenciar a lógica do jogo, enquanto o navegador do usuário fornece uma interface visual interativa e amigável.

Esperamos ter alcançado o objetivo de criar  um jogo divertido e de demonstrar a flexibilidade da comunicação WebSocket para aplicações em tempo real, onde cada movimento do jogador é processado instantaneamente pelo ESP32.

A implementação de recursos como UNDO, REDO, TopTen e Estatísticas buscaram trazer uma experiência mais completa para o usuário final. Com as possibilidades de personalização e expansão, este projeto abre portas para aplicações interativas mais complexas e inovadoras no futuro.

A abordagem cooperativa entre o ESP32 e o navegador mostra o potencial de projetos que podem unir o melhor de ambos os mundos: a eficiência do microcontrolador e a versatilidade das interfaces web. O jogo JawBreaker, portanto, não apenas procurou proporcionar entretenimento, mas também trazer o exemplo de integração entre tecnologia de microcontroladores e a web.


Sobre o Autor


Alberto de Almeida Menezes
tinho.menezes@gmail.com

Bacharel em Engenharia de Áudio e Produção Musical pela Berklee College of Music.


Dailton de Oliveira Menezes
dailton.menezes@gmail.com

Bacharel em Ciência da Computação pela Universidade Federal de Minas Gerais.


Eletrogate

29 de janeiro de 2025

A Eletrogate é uma loja virtual de componentes eletrônicos do Brasil e possui diversos produtos relacionados à Arduino, Automação, Robótica e Eletrônica em geral.

Tenha a Metodologia Eletrogate dentro da sua Escola! Conheça nosso Programa de Robótica nas Escolas!

Eletrogate Robô

Assine nossa newsletter e
receba  10% OFF  na sua
primeira compra!