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.
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.
Antes de iniciar a programação, é preciso fazer a instalação das placas da Espressif e das seguintes bibliotecas:
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.
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
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
Os parâmetros da aplicação são armazenados no SPIFFS no formato JSON no arquivo /config.json, conforme estrutura a seguir.
{ "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 }
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."); } }
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:
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
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
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.
|
|
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!