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_HCó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.
Conheça a Metodologia Eletrogate e Lecione um Curso de Robótica nas Escolas da sua Região!