[init] Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
.pio
|
||||
.vscode/.browse.c_cpp.db*
|
||||
.vscode/c_cpp_properties.json
|
||||
.vscode/launch.json
|
||||
.vscode/ipch
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": [
|
||||
"platformio.platformio-ide"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
"ms-vscode.cpptools-extension-pack"
|
||||
]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"EEPROM",
|
||||
"Pico",
|
||||
"TXTDIM"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
# Vier op een Rij AI: Hoe denkt de computer?
|
||||
|
||||
## 1. Het Virtuele Bord
|
||||
|
||||
De computer ziet geen gekleurde schijfjes op een bord. Voor de computer is het bord een tabel met cijfers:
|
||||
|
||||
- **0** = Lege plek
|
||||
- **1** = Gele schijf
|
||||
- **2** = Rode schijf
|
||||
|
||||
Het bord heeft 7 kolommen en 6 rijen. Na elke zet controleert de computer alle richtingen (horizontaal, verticaal en schuin) om te kijken of iemand vier op een rij heeft.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wat is een "Ply"?
|
||||
|
||||
Een **ply** is één zet van een speler. Als de AI op ply 6 staat, kijkt hij 6 zetten vooruit. Omdat spelers om de beurt spelen, betekent ply 6 dat de AI 3 van zijn eigen zetten en 3 zetten van de tegenstander bedenkt.
|
||||
|
||||
Meer plies = slimmere zetten, maar het duurt langer om te berekenen. Op de ESP32-C3 is ply 4 bijna meteen klaar, ply 6 duurt ongeveer een seconde, en ply 8-10 kan enkele seconden duren. Terwijl de AI nadenkt, zie je een knipperend lichtje.
|
||||
|
||||
---
|
||||
|
||||
## 3. De Minimax Strategie
|
||||
|
||||
### Het basisidee
|
||||
|
||||
Stel je voor dat je Vier op een Rij speelt tegen een vriend. Voordat je je schijfje laat vallen, denk je: "Als ik mijn schijfje hier leg, wat doet mijn vriend dan? En wat doe ik daarna?" Dat is precies wat de computer doet, alleen kijkt hij naar **alle** mogelijke zetten, niet alleen een paar.
|
||||
|
||||
### Twee spelers, twee doelen
|
||||
|
||||
De AI noemt de twee spelers **Max** (de computer zelf) en **Min** (jij):
|
||||
|
||||
- **Max** wil de hoogste score (de computer wint).
|
||||
- **Min** wil de laagste score (jij wint).
|
||||
|
||||
De AI gaat ervan uit dat jij altijd je beste zet doet. Hij hoopt niet dat jij een fout maakt.
|
||||
|
||||
### Een eenvoudig voorbeeld
|
||||
|
||||
Stel je voor dat er nog maar 3 kolommen over zijn en de AI kijkt 2 zetten vooruit. Hij maakt een soort boom:
|
||||
|
||||
```text
|
||||
Beurt van de AI (Max - kiest de hoogste score)
|
||||
/ | \\
|
||||
kolom 2 kolom 3 kolom 4
|
||||
/ \\ / \\ / \\
|
||||
Jouw beurt (Min - kiest de laagste score)
|
||||
... ... ... ... ... ...
|
||||
+5 -3 +2 +8 -1 +4
|
||||
```
|
||||
|
||||
1. Na kolom 2: jij zou de laagste score kiezen (-3).
|
||||
2. Na kolom 3: jij zou de laagste score kiezen (+2).
|
||||
3. Na kolom 4: jij zou de laagste score kiezen (-1).
|
||||
|
||||
De AI kiest dan de kolom met de hoogste score die overblijft, in dit geval kolom 3 (+2).
|
||||
|
||||
### Scoren: Hoe de AI een Bord Waardeert
|
||||
|
||||
Na het doorspelen van een "wat als?"-scenario, moet de AI beslissen: is dit een goed of slecht resultaat? Hij gebruikt een eenvoudig scoringsysteem met drie mogelijke uitkomsten:
|
||||
|
||||
- **+1000 of meer: "Ik win!"** De AI heeft een manier gevonden om vier op een rij te krijgen. Hoe sneller hij kan winnen, hoe hoger de score. Winnen in 2 zetten scoort hoger dan winnen in 6 zetten. Daarom gaat de AI altijd voor de snelste overwinning.
|
||||
|
||||
- **-1000 of minder: "Ik verlies!"** De tegenstander krijgt vier op een rij. Hoe sneller hij verliest, hoe slechter de score. Dit zorgt ervoor dat de AI het hardst vecht tegen zetten die dreigen tot een direct verlies.
|
||||
|
||||
- **0: "Ik weet het nog niet."** De AI heeft zo ver vooruit gekeken als hij kon (hij is door zijn plies heen) en niemand heeft gewonnen. Hij noemt deze positie "neutraal" — niet goed, niet slecht.
|
||||
|
||||
Dat is alles — de AI geeft geen extra punten voor drie op een rij, het controleren van het midden, of andere slimme trucs. Hij vertrouwt volledig op het ver vooruit kijken om te bepalen welke zetten tot een overwinning leiden en welke niet. Als hij binnen zijn zoekdiepte geen winst of verlies ziet, ziet elke positie er hetzelfde uit.
|
||||
|
||||
### Waarom de middelste kolom belangrijk is
|
||||
|
||||
Ook al geeft de AI geen bonuspunten voor spelen in het midden,
|
||||
hij controleert altijd eerst de middelste kolom (kolom 3), en werkt dan naar buiten toe (2, 4, 1, 5, 0, 6).
|
||||
De middelste kolom is betrokken bij meer mogelijke winnende lijnen dan de randen, dus door deze eerst te controleren,
|
||||
vindt de AI sneller goede zetten en kan hij slechte zetten eerder overslaan (dankzij alpha-beta snoeien).
|
||||
|
||||
---
|
||||
|
||||
## 4. Alpha-Beta Snoeien: De Slimme Snelweg
|
||||
|
||||
### Het probleem
|
||||
|
||||
Acht plies vooruit kijken in Vier op een Rij betekent miljoenen bordposities verkennen.
|
||||
Zelfs een snelle microcontroller kan ze niet allemaal in een redelijke tijd controleren.
|
||||
|
||||
### De oplossing
|
||||
|
||||
**Alpha-Beta pruning** is een manier om takken van de boom over te slaan die de uiteindelijke beslissing niet kunnen veranderen.
|
||||
_"to prune" betekent snoeien of snijden_
|
||||
|
||||
Stel je voor dat je een verjaardagscadeau koopt.
|
||||
Je gaat naar Winkel A en vindt een leuk speelgoed voor 10 euro. Dan ga je naar Winkel B.
|
||||
Het eerste artikel dat je ziet, kost 15 euro, en je merkt dat alles in Winkel B nog duurder is.
|
||||
Je hoeft niet elk artikel in Winkel B te controleren — je weet al dat Winkel A beter is.
|
||||
Je verlaat Winkel B en bespaart tijd.
|
||||
|
||||
De AI doet hetzelfde:
|
||||
|
||||
- **Alpha** is de beste score die de AI (Max) tot nu toe heeft gevonden. Denk hierbij aan: "Ik weet al dat ik ten minste dit goed kan doen."
|
||||
- **Beta** is de beste score die de tegenstander (Min) tot nu toe heeft gevonden. Denk hierbij aan: "De tegenstander weet al dat hij mij tot hoogstens dit kan beperken."
|
||||
|
||||
Wanneer de AI een tak verkent en ontdekt dat de score nooit beter kan worden dan wat hij al heeft (beta <= alpha),
|
||||
**snijdt** hij (snijdt hij af) die hele tak. Hij slaat alle overgebleven zetten in die tak over, omdat ze het resultaat niet kunnen veranderen.
|
||||
|
||||
### Hoeveel helpt het?
|
||||
|
||||
In de praktijk laat snoeien de AI 50-90% van de posities overslaan die hij anders zou moeten controleren.
|
||||
Daarom is de volgorde van de kolommen belangrijk — de AI controleert eerst de middelste kolom (kolom 3) en werkt dan naar buiten toe.
|
||||
Goede zetten zitten vaak in het midden, dus door deze eerst te controleren, leidt dat tot betere snoeiing en een snellere zoektocht.
|
||||
|
||||
---
|
||||
|
||||
## 5. De Drie Fases van de AI
|
||||
|
||||
De AI doet zijn werk in drie stappen:
|
||||
|
||||
1. **Kan ik nu winnen?** De AI probeert in elke kolom een schijfje te leggen. Als hij ergens vier op een rij kan maken, doet hij dat meteen. Geen verdere berekeningen nodig.
|
||||
2. **Kan de tegenstander volgende beurt winnen?** De AI controleert of jij ergens vier op een rij kunt maken. Zo ja, dan blokkeert hij die kolom. Dit overslaan zou een grote fout zijn.
|
||||
3. **Diepe zoektocht.** Als er geen directe winst of bedreiging is, voert de AI de volledige minimax-strategie uit met alpha-beta snoeien.
|
||||
|
||||
Deze drie stappen maken de AI zowel snel (directe reacties op duidelijke zetten) als slim (diep nadenken als het nodig is).
|
||||
|
||||
---
|
||||
|
||||
## 6. Demo-modus: Verschillende Vaardigheden
|
||||
|
||||
In de demo-modus spelen twee AI's tegen elkaar.
|
||||
Om het spannend te maken (in plaats van altijd gelijkspel), krijgt elke speler willekeurig een andere diepte toegekend.
|
||||
De ene speler kijkt bijvoorbeeld 5 zetten vooruit, de andere maar 3.
|
||||
De sterkere speler kan zo winnende zetten vinden die de zwakkere mist. Wie sterker is, wordt elke keer willekeurig bepaald.
|
||||
|
||||
---
|
||||
|
||||
## 7. Blunder-modus
|
||||
|
||||
Normaal speelt de AI altijd de beste zet die hij kan vinden. Maar dat kan frustrerend zijn voor jongere of minder ervaren spelers die nooit winnen. De **blunder-modus** geeft de AI een instelbare kans (bijvoorbeeld 20%) om een willekeurige zet te doen in plaats van diep na te denken. Als er een blunder gebeurt, slaat de AI zijn slimme analyse over en laat hij een schijfje in een willekeurige open kolom vallen. De rest van de tijd speelt hij gewoon op volle kracht — maar af en toe maakt hij een domme fout die een oplettende speler kan afstraffen.
|
||||
|
||||
Blunders gaan nooit boven een directe winst of blokkade. Als de AI nu kan winnen, of als de tegenstander op het punt staat te winnen, maakt de AI altijd de juiste zet. Blunders vervangen alleen de diepe zoektocht op beurten waar er geen directe dreiging is.
|
||||
|
||||
---
|
||||
|
||||
## 8. Snelle Bediening
|
||||
|
||||
De ESP32-C3 heeft maar één kern. Als de AI nadenkt, kan hij de bediening een paar seconden blokkeren.
|
||||
Twee trucs zorgen ervoor dat het spel soepel blijft:
|
||||
|
||||
1. **Knopcontrole tijdens het zoeken:** Tijdens het nadenken controleert de AI af en toe of je op de knop hebt gedrukt. Zo ja, stopt hij meteen met nadenken.
|
||||
|
||||
2. **Stopvlag:** Een wereldwijde vlag (`abortAi`) zorgt ervoor dat het nadenken meteen stopt als je op de knop drukt. Binnen microseconden stopt de hele berekening.
|
||||
|
||||
---
|
||||
|
||||
## Meer Weten?
|
||||
|
||||
- [Vier op een Rij - Wiskundige Oplossing (Wikipedia)](https://nl.wikipedia.org/wiki/Vier_op_een_rij)
|
||||
- [Minimax Algorithme (Wikipedia)](https://nl.wikipedia.org/wiki/Minimax)
|
||||
- [Alpha-Beta Pruning (Wikipedia)](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Connect 4 AI: How the Computer Thinks
|
||||
|
||||
## 1. The Virtual Board
|
||||
|
||||
The computer doesn't see colored discs on a grid. It sees a table of numbers:
|
||||
|
||||
- **0** = Empty space
|
||||
- **1** = Yellow disc
|
||||
- **2** = Red disc
|
||||
|
||||
The board has 7 columns and 6 rows. After every move, a scan function checks all directions (horizontal, vertical, and both diagonals) to see if anyone has four in a row.
|
||||
|
||||
## 2. What is a "Ply"?
|
||||
|
||||
A **ply** is one move by one player. If the AI is set to ply 6, it looks 6 individual moves into the future. Since players alternate turns, ply 6 means the AI considers 3 of its own moves and 3 of the opponent's moves.
|
||||
|
||||
More plies = stronger play, but takes longer to calculate. On the ESP32-C3, ply 4 is nearly instant, ply 6 takes about a second, and ply 8-10 can take several seconds. The AI shows a pulsing light while it is thinking.
|
||||
|
||||
## 3. The Minimax Strategy
|
||||
|
||||
### The basic idea
|
||||
|
||||
Imagine you are playing Connect 4 against a friend. Before you drop your disc, you think: "If I put my disc here, what will my friend do? And then what would I do after that?"
|
||||
|
||||
That is exactly what the computer does, except it checks **every** possible move, not just a few.
|
||||
|
||||
### Two players, two goals
|
||||
|
||||
The AI calls the two players **Max** (itself) and **Min** (you):
|
||||
|
||||
- **Max** wants the highest possible score (the AI winning).
|
||||
- **Min** wants the lowest possible score (you winning).
|
||||
|
||||
The AI assumes you will always make your best move. It doesn't hope you'll make a mistake.
|
||||
|
||||
### A simple example
|
||||
|
||||
Imagine there are only 3 columns left and the AI can look 2 moves ahead. It builds a tree like this:
|
||||
|
||||
```
|
||||
AI's turn (Max - pick the highest)
|
||||
/ | \
|
||||
col 2 col 3 col 4
|
||||
/ \ / \ / \
|
||||
Your turn (Min - pick the lowest)
|
||||
... ... ... ... ... ...
|
||||
+5 -3 +2 +8 -1 +4
|
||||
```
|
||||
|
||||
1. After column 2: you would pick the move scoring -3 (lowest = best for you).
|
||||
2. After column 3: you would pick the move scoring +2.
|
||||
3. After column 4: you would pick the move scoring -1.
|
||||
|
||||
The AI compares -3, +2, and -1, and picks column 3 because +2 is the best it can guarantee.
|
||||
|
||||
### Scoring: How the AI Rates a Board
|
||||
|
||||
After playing out a "what if?" scenario, the AI needs to decide: is this a good result or a bad one? It uses a very simple scoring system with only three possible outcomes:
|
||||
|
||||
- **+1000 or more: "I win!"** The AI found a way to get four in a row. The bonus points above 1000 depend on how quickly it can win. Winning in 2 moves scores higher than winning in 6 moves. This is why the AI always goes for the fastest victory — it never wastes time when it can finish the game.
|
||||
|
||||
- **-1000 or less: "I lose!"** The opponent gets four in a row. Losing sooner gets an even worse score. This makes the AI fight hardest against moves that threaten an immediate loss.
|
||||
|
||||
- **0: "I don't know yet."** The AI looked as far ahead as it could (it ran out of plies) and nobody won. It simply calls this position "neutral" — not good, not bad.
|
||||
|
||||
That's it — the AI does not give extra points for having three in a row, controlling the center, or any other clever trick. It relies entirely on looking many moves ahead to figure out which moves lead to wins and which ones don't. If it can't see a win or loss within its search depth, every position looks the same.
|
||||
|
||||
### Why the center column matters
|
||||
|
||||
Even though the AI doesn't give bonus points for playing in the center, it always checks the center column first (column 3), then works outward (2, 4, 1, 5, 0, 6).
|
||||
The center column is involved in more possible winning lines than the edges, so checking it first helps the AI find good moves faster and skip bad ones sooner (thanks to alpha-beta pruning).
|
||||
|
||||
## 4. Alpha-Beta Pruning: The Smart Shortcut
|
||||
|
||||
### The problem
|
||||
|
||||
Looking ahead 8 plies in Connect 4 means exploring millions of board positions.
|
||||
Even a fast microcontroller can't check them all in a reasonable time.
|
||||
|
||||
### The solution
|
||||
|
||||
**Alpha-Beta pruning** is a way to skip branches of the tree that can't possibly change the final decision.
|
||||
|
||||
Think of it like shopping for a birthday present. You visit Shop A and find a nice toy for 10 euros.
|
||||
Then you go to Shop B. The first item you see costs 15 euros, and you notice everything else in Shop B is even more expensive.
|
||||
You don't need to check every item in Shop B - you already know Shop A is better. You leave Shop B and save time.
|
||||
|
||||
The AI does the same thing:
|
||||
|
||||
- **Alpha** is the best score the AI (Max) has found so far. Think of it as "I already know I can do at least this well."
|
||||
- **Beta** is the best score the opponent (Min) has found so far. Think of it as "The opponent already knows they can limit me to at most this."
|
||||
|
||||
When the AI is exploring a branch and discovers that the score can never beat what it already has (beta <= alpha), it **prunes** (cuts off) that entire branch. It skips all remaining moves in that branch because they can't change the outcome.
|
||||
|
||||
### How much does it help?
|
||||
|
||||
In practice, pruning lets the AI skip 50-90% of the positions it would otherwise need to check. This is why the column order matters - the AI checks the center column first (column 3), then works outward. Good moves tend to be near the center, so checking them first leads to better pruning and faster search.
|
||||
|
||||
## 5. The Three-Phase Move Strategy
|
||||
|
||||
Before running the expensive minimax search, the AI takes two quick shortcuts:
|
||||
|
||||
1. **Can I win right now?** The AI tries placing its disc in each column. If any column completes four in a row, it takes that move immediately. No need to think further.
|
||||
|
||||
2. **Can my opponent win next turn?** The AI checks if the opponent could win by playing in any column. If so, it blocks that column. Missing this would be a fatal mistake.
|
||||
|
||||
3. **Deep search.** Only if there are no immediate wins or threats does the AI run the full minimax search with alpha-beta pruning.
|
||||
|
||||
This three-phase approach makes the AI both fast (instant reactions to obvious moves) and smart (deep strategic thinking when needed).
|
||||
|
||||
## 6. Demo Mode: Asymmetric Skill
|
||||
|
||||
In demo mode, two AI players play against each other. To make the games interesting (rather than always ending in a draw), each player is randomly assigned a different search depth. One player might look 5 moves ahead while the other only looks 3 moves ahead. The stronger player can find winning setups that the weaker one misses, leading to exciting games with real winners. Who gets the advantage is randomized each game.
|
||||
|
||||
## 7. Blunder Mode
|
||||
|
||||
Normally, the AI always plays the best move it can find. But that can be frustrating for younger or casual players who never get to win. **Blunder mode** gives the AI a configurable chance (for example 20%) to make a random move instead of running the deep minimax search. When a blunder happens, the AI simply drops a disc in a random open column. It still plays normally the rest of the time, so the game feels real - but every now and then the AI makes a silly mistake that a sharp player can punish.
|
||||
|
||||
Blunders never override an instant win or block. If the AI can win right now, or if the opponent is about to win, the AI always makes the correct move. Blunders only replace the deep search on turns where there is no immediate threat.
|
||||
|
||||
## 8. Responsive Controls
|
||||
|
||||
The ESP32-C3 is a single-core processor. When the AI is thinking, it could block all input for several seconds. Two techniques keep the game responsive:
|
||||
|
||||
1. **Mid-search button checks:** During the minimax search, the AI periodically checks whether the player has pressed the button. If so, it immediately abandons the search.
|
||||
|
||||
2. **Abort flag:** A global flag (`abortAi`) propagates through all levels of the recursive search. Once set, every level of the search returns immediately, unwinding the entire calculation in microseconds.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Connect Four - Mathematical Solution (Wikipedia)](https://en.wikipedia.org/wiki/Connect_Four#Mathematical_solution)
|
||||
- [Minimax Algorithm (Wikipedia)](https://en.wikipedia.org/wiki/Minimax)
|
||||
- [Alpha-Beta Pruning (Wikipedia)](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Connect Four Pico 2W
|
||||
|
||||
Embedded Connect Four game with AI, running on a Raspberry Pi Pico 2W with a PicoResTouch-LCD-2.8 touchscreen (2.8" TFT + resistive touch).
|
||||
|
||||
## Build & Upload
|
||||
|
||||
```bash
|
||||
# Build
|
||||
pio run
|
||||
|
||||
# Upload to board
|
||||
pio run --target upload
|
||||
|
||||
# Monitor serial output
|
||||
pio device monitor
|
||||
```
|
||||
|
||||
Requires PlatformIO CLI. The project uses the Arduino framework (earlephilhower core).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
config.h — Pin definitions, layout constants, colors, game defaults
|
||||
game.h / .cpp — State enum, shared globals, board helpers, AI (minimax), game logic
|
||||
touch.h / .cpp — XPT2046 raw reads, touch events, calibration
|
||||
storage.h / .cpp — EEPROM persistence (calibration, settings, game log)
|
||||
display.h / .cpp — All TFT drawing (board, menu, status, settings UI, game log UI)
|
||||
main.cpp — setup(), loop(), state dispatch
|
||||
```
|
||||
|
||||
Other files:
|
||||
- `platformio.ini` — Build configuration and tunable parameters
|
||||
- `connect_four.js` / `connect_four.html` — Browser edition (standalone, identical AI)
|
||||
- `README.md` — Technical and practical documentation
|
||||
- `Background information.md` — AI strategy explanation (accessible to all ages)
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
- **Hardware**: Raspberry Pi Pico 2W (RP2350), PicoResTouch-LCD-2.8 (ST7789 TFT 320x240, XPT2046 resistive touch)
|
||||
- **AI**: Minimax with alpha-beta pruning, three-phase move strategy (instant win/block, blunder, deep search)
|
||||
- **Display**: Adafruit_ST7789 + Adafruit_GFX via SPI1, portrait mode (240x320), dark theme matching the JS canvas implementation
|
||||
- **Touch**: Raw XPT2046 SPI reads (shared SPI1 bus with display). Calibration on first boot, stored in EEPROM. Hold touch during boot to recalibrate.
|
||||
- **Persistence**: Settings and game log stored in EEPROM (flash-backed). Survives reboots.
|
||||
- **Build flags**: Tunable game parameters defined as `-D` flags in `platformio.ini`
|
||||
- **Dependencies**: Adafruit ST7735/ST7789 Library, Adafruit GFX Library, EEPROM + SPI (built-in)
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
- **config.h**: Constants only. No state, no functions. All pin defs, layout, colors, defaults.
|
||||
- **game.h/cpp**: Owns all shared globals (`board`, `gameState`, `tft`, etc.) as extern/definitions. Board helpers, scanBoard, evaluateBoard, minimax, computeAiMove.
|
||||
- **touch.h/cpp**: Owns touch calibration globals. readRawTouch, getTouchDown, colFromTouch, menuItemFromTouch, calibrateTouch.
|
||||
- **storage.h/cpp**: EEPROM layout defines. save/load for calibration, settings, game log. logGame, clearGameLog.
|
||||
- **display.h/cpp**: All drawing. drawCell, drawBoardFull, drawMenu, drawSettings, drawGameLogScreen, animateDrop. Settings/game log touch handlers.
|
||||
- **main.cpp**: setup, loop, state handlers (startGame, returnToMenu, startDemo, handleAiTurn, handleDemoStep, handleFlash).
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Naming**: camelCase for all variables and functions
|
||||
- **Game states**: Use enum names (`MENU`, `PLAYING`, `AI_TURN`, `FINISHED_WIN`, `FINISHED_DRAW`, `DEMO`, `SETTINGS`, `GAME_LOG`), never magic numbers
|
||||
- **Player colors**: Use `playerColor(player)` helper, never inline color constants directly
|
||||
- **Board reset**: Use `resetBoard()`, never bare `memset(board, 0, sizeof(board))`
|
||||
- **Column priority**: Use the shared `colOrder[]` constant, never duplicate `{3, 2, 4, 1, 5, 0, 6}`
|
||||
- **Duplicate logic**: The win/draw/log check is handled by `checkGameEnd()` — do not duplicate this pattern
|
||||
- **Colors**: RGB565 constants prefixed with `C_`, derived from the JS canvas palette via the `C565(r,g,b)` macro
|
||||
|
||||
## Display Layout (portrait 240x320)
|
||||
|
||||
- Cell size: 32px, disc radius: 13px
|
||||
- Board origin: (8, 56), board size: 224x192
|
||||
- Column numbers at y=44, status text at y=254, sub-status at y=278
|
||||
- Board background with rounded corners, grid lines between cells, circular discs in cells
|
||||
|
||||
## Demo Mode
|
||||
|
||||
Demo uses asymmetric ply depths (`demoPly[0]` and `demoPly[1]`) randomized at each demo game start via `randomizeDemoPlies()`. This ensures games produce winners rather than draws while still looking strategic.
|
||||
|
||||
## Game Log
|
||||
|
||||
- Stored as `GameEntry` structs (type, level, winner, moves), not as formatted strings
|
||||
- Persisted to EEPROM after each game via `saveGameLog()` / `loadGameLog()`
|
||||
- Viewable on-device via Settings > View Game Log (paginated, newest first)
|
||||
- Max entries configurable via `MAX_GAME_LOG` build flag
|
||||
- Player wins highlighted in red
|
||||
|
||||
## Settings Menu
|
||||
|
||||
Accessible from the main menu (4th item). Allows adjusting:
|
||||
- AI Ply depth (1-10)
|
||||
- Blunder mode ON/OFF
|
||||
- Blunder chance (5-100%, step 5)
|
||||
- View Game Log (paginated, with clear option)
|
||||
- Recalibrate touch screen
|
||||
|
||||
Settings are persisted to EEPROM on exit.
|
||||
|
||||
## EEPROM Layout
|
||||
|
||||
- Offset 0: Touch calibration magic + data (9 bytes)
|
||||
- Offset 9: Settings magic + lookAhead, blunderEnabled, blunderChance, gameLogCount (5+1 bytes)
|
||||
- Offset 16+: Game log entries (46 bytes each)
|
||||
|
||||
## Commit Style
|
||||
|
||||
Follow the existing pattern: `[type] Description.` where type is `fix`, `update`, `add`, or `refactor`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 MiB |
@@ -0,0 +1,125 @@
|
||||
# Connect 4 - ESP32
|
||||
|
||||
A Connect 4 game with AI for the ESP32-C3 (Lolin C3 Mini), using an 8x8 NeoPixel LED matrix and rotary encoder.
|
||||
|
||||
## Hardware
|
||||
|
||||
### Pin Mapping (Lolin C3 Mini)
|
||||
|
||||
| Component | ESP32-C3 Pin | Function |
|
||||
| :------------------- | :----------- | :------------------- |
|
||||
| **NeoPixel Matrix** | `GPIO 4` | Data Input (DIN) |
|
||||
| **Rotary Encoder A** | `GPIO 0` | Directional CLK |
|
||||
| **Rotary Encoder B** | `GPIO 1` | Directional DT |
|
||||
| **Encoder Button** | `GPIO 2` | Selection/Abort (SW) |
|
||||
|
||||
### LED Matrix Layout
|
||||
|
||||
The 8x8 matrix (64 LEDs) is used as follows:
|
||||
|
||||
```
|
||||
Row 0: [ col0 ] [ col1 ] [ col2 ] [ col3 ] [ col4 ] [ col5 ] [ col6 ] [ indicator ]
|
||||
Row 1: [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ ---- ] [ border ]
|
||||
Row 2-7:[ game board: 7 columns x 6 rows ] [ border ]
|
||||
```
|
||||
|
||||
- **Row 0 (columns 0-6):** Interaction row. Shows the current column selection cursor and AI thinking animation (pulsing disc).
|
||||
- **Row 0 (column 7):** Game mode indicator LED (dim). Yellow = player is yellow vs AI, Red = player is red vs AI, Blue = two player, Off = demo.
|
||||
- **Row 1 + Column 7:** Blue border frame (toggleable via `SHOW_BORDER` build flag). Glows softly during demo and finished states.
|
||||
- **Rows 2-7, Columns 0-6:** The 7x6 game board. Yellow and Red discs.
|
||||
- **LED index formula:** `index = (y * 8) + x`
|
||||
|
||||
## Game Modes
|
||||
|
||||
Use the rotary encoder to select a mode, press the button to start:
|
||||
|
||||
1. **Player vs AI (Yellow)** - Player plays yellow (first move), AI plays red.
|
||||
2. **Player vs AI (Red)** - AI plays yellow (first move), player plays red.
|
||||
3. **Two Player** - Two humans alternate turns. Yellow goes first.
|
||||
|
||||
### Demo Mode
|
||||
|
||||
When idle (no input for the configured timeout), the board enters demo mode where two AI players play against each other automatically. To make games more interesting, the two demo players are assigned different skill levels (asymmetric ply depths), so games frequently end in a win rather than a draw. Press the button or turn the encoder to exit demo mode and return to the menu.
|
||||
|
||||
### Animations
|
||||
|
||||
- **Disc drop:** Discs fall from the top with accelerating speed.
|
||||
- **Column movement:** Discs slide across the top row to the selected column.
|
||||
- **AI thinking:** The disc in the selected column pulses while the AI calculates.
|
||||
- **Win:** The winning four discs flash while the rest of the board dims. Displayed for 30 seconds.
|
||||
- **Draw:** All discs blink on and off.
|
||||
|
||||
## WiFi Admin Interface
|
||||
|
||||
The ESP32 creates a WiFi access point:
|
||||
|
||||
- **Network:** Configured via `WIFI_SSID` build flag (default: `Connect4`)
|
||||
- **Password:** Configured via `WIFI_PASSWORD` build flag (default: `youlose4`)
|
||||
- **Admin page:** Connect to the network and open `http://192.168.4.1`
|
||||
|
||||
### Settings (via web interface)
|
||||
|
||||
| Setting | Description |
|
||||
| :--------------- | :------------------------------------------------------- |
|
||||
| **Base AI Ply** | Search depth for the AI (1-10). Higher = stronger. |
|
||||
| **Brightness** | LED brightness (0-255). |
|
||||
| **Idle Timeout** | Seconds of inactivity before demo mode starts. |
|
||||
| **Blunders** | AI randomly picks a bad move at the configured chance %. |
|
||||
|
||||
Settings are saved to flash (NVS) and persist across reboots.
|
||||
|
||||
### Game Log
|
||||
|
||||
The web interface shows a log of the last N games (configurable via `MAX_GAME_LOG` build flag). Each entry shows the game type, AI level, winner, and the sequence of columns played. Games where a player beats the computer are highlighted in red. The game log is persisted to flash and survives reboots.
|
||||
|
||||
## Build & Upload
|
||||
|
||||
Requires [PlatformIO](https://platformio.org/) CLI or VS Code with PlatformIO extension.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
pio run
|
||||
|
||||
# Upload to board
|
||||
pio run --target upload
|
||||
|
||||
# Monitor serial output
|
||||
pio device monitor
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- [FastLED](https://github.com/FastLED/FastLED) >= 3.6.0 - LED control
|
||||
- [Encoder](https://github.com/PaulStoffregen/Encoder) >= 1.4.4 - Rotary encoder input
|
||||
- Preferences (built-in) - Persistent settings storage
|
||||
- WiFi / WebServer (built-in) - Admin interface
|
||||
|
||||
### Build Flags
|
||||
|
||||
All configurable parameters are defined as `-D` flags in `platformio.ini`:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| :--------------------- | :--------- | :------------------------------------------------- |
|
||||
| `LED_PIN` | `4` | GPIO pin for NeoPixel data line |
|
||||
| `ENC_A` | `0` | GPIO pin for encoder CLK |
|
||||
| `ENC_B` | `1` | GPIO pin for encoder DT |
|
||||
| `ENC_SW` | `2` | GPIO pin for encoder button |
|
||||
| `SENSITIVITY` | `4` | Encoder steps per detent (higher = less sensitive) |
|
||||
| `SHOW_BORDER` | `1` | Show blue border frame (0 = off, 1 = on) |
|
||||
| `DEFAULT_LOOK_AHEAD` | `8` | Default AI search depth (plies) |
|
||||
| `DEFAULT_BRIGHTNESS` | `25` | Default LED brightness (0-255) |
|
||||
| `DEFAULT_IDLE_TIMEOUT` | `45` | Seconds before demo mode activates |
|
||||
| `DEMO_RESET_PAUSE` | `30000` | Milliseconds before finished game enters demo |
|
||||
| `MAX_GAME_LOG` | `5` | Number of games stored in the game log |
|
||||
| `WIFI_SSID` | `Connect4` | SSID for the WiFi access point |
|
||||
| `WIFI_PASSWORD` | `youlose4` | Password for the WiFi access point |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/main.cpp Single-file application (all game logic, AI, LED, web server)
|
||||
platformio.ini Build configuration, pin mappings, and tunable parameters
|
||||
README.md This file - technical and practical information
|
||||
Background information.md How the AI works (suitable for all ages)
|
||||
CLAUDE.md AI assistant project context
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connect Four</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
canvas {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<script src="connect_four.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+854
@@ -0,0 +1,854 @@
|
||||
/* ============================================================
|
||||
* Connect Four — Browser Edition
|
||||
* A single-file game: AI (minimax + alpha-beta), demo mode,
|
||||
* game log (localStorage), blunder mode, idle timeout.
|
||||
*
|
||||
* Include this script in an HTML page that has:
|
||||
* <canvas id="gameCanvas"></canvas>
|
||||
*
|
||||
* Works in Firefox, Chrome, Edge, Safari, and Brave.
|
||||
* ============================================================ */
|
||||
|
||||
// --- Configurable Parameters --------------------------------
|
||||
const COLS = 7; // board columns
|
||||
const ROWS = 6; // board rows
|
||||
const LOOK_AHEAD = 8; // AI search depth (plies)
|
||||
const BLUNDER_ENABLED = false; // allow random AI mistakes
|
||||
const BLUNDER_CHANCE = 20; // percent chance of blunder (0-100)
|
||||
const DEMO_RESET_PAUSE = 5; // seconds before auto-demo after game end
|
||||
const IDLE_TIMEOUT = 60; // seconds of inactivity before demo starts
|
||||
const MAX_GAME_LOG = 100; // max stored game entries (localStorage)
|
||||
|
||||
// --- Visual Parameters --------------------------------------
|
||||
const CELL_SIZE = 70; // pixel size of each board cell
|
||||
const DISC_RADIUS = 28; // radius of a disc
|
||||
const BOARD_PAD_TOP = 100; // space above the board (cursor + col numbers)
|
||||
const BOARD_PAD_X = 40; // horizontal padding
|
||||
const BOARD_PAD_BOTTOM = 40; // space below the board
|
||||
const ANIM_DROP_SPEED = 1200; // pixels per second for drop animation
|
||||
const FONT_FAMILY = "system-ui, -apple-system, sans-serif";
|
||||
|
||||
// --- Colors -------------------------------------------------
|
||||
const COLOR_BG = "#1a1a2e";
|
||||
const COLOR_BOARD = "#16213e";
|
||||
const COLOR_GRID_LINE = "#0f3460";
|
||||
const COLOR_EMPTY = "#0a1628";
|
||||
const COLOR_P1 = "#ffd700"; // Yellow (player 1)
|
||||
const COLOR_P2 = "#e63946"; // Red (player 2)
|
||||
const COLOR_P1_DIM = "#8b7500";
|
||||
const COLOR_P2_DIM = "#7a1f26";
|
||||
const COLOR_HIGHLIGHT = "#ffffff";
|
||||
const COLOR_TEXT = "#e0e0e0";
|
||||
const COLOR_TEXT_DIM = "#666680";
|
||||
const COLOR_MENU_BG = "#1a1a2e";
|
||||
const COLOR_MENU_SEL = "#0f3460";
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const COL_ORDER = [3, 2, 4, 1, 5, 0, 6];
|
||||
|
||||
const State = Object.freeze({
|
||||
MENU: 0,
|
||||
PLAYING: 1,
|
||||
AI_TURN: 2,
|
||||
FINISHED_WIN: 3,
|
||||
FINISHED_DRAW: 4,
|
||||
DEMO: 5,
|
||||
});
|
||||
|
||||
// --- Canvas setup -------------------------------------------
|
||||
const canvas = document.getElementById("gameCanvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const BOARD_W = COLS * CELL_SIZE;
|
||||
const BOARD_H = ROWS * CELL_SIZE;
|
||||
const CANVAS_W = BOARD_W + BOARD_PAD_X * 2;
|
||||
const CANVAS_H = BOARD_PAD_TOP + BOARD_H + BOARD_PAD_BOTTOM;
|
||||
|
||||
canvas.width = CANVAS_W;
|
||||
canvas.height = CANVAS_H;
|
||||
canvas.style.display = "block";
|
||||
canvas.style.margin = "0 auto";
|
||||
canvas.tabIndex = 0;
|
||||
canvas.focus();
|
||||
|
||||
// --- Game state ---------------------------------------------
|
||||
let board = makeBoard();
|
||||
let gameState = State.MENU;
|
||||
let menuMode = 0;
|
||||
let currentPlayer = 1;
|
||||
let activeCol = 3;
|
||||
let winnerPlayer = 0;
|
||||
let winPositions = [];
|
||||
let currentMoves = "";
|
||||
let gameMenuMode = 0;
|
||||
let gameLevel = LOOK_AHEAD;
|
||||
let games = loadGameLog();
|
||||
let demoPly = [4, 4];
|
||||
let lastActivity = performance.now() / 1000;
|
||||
let demoResetTimer = 0;
|
||||
let flashToggle = true;
|
||||
let lastFlash = 0;
|
||||
let hoverCol = -1;
|
||||
|
||||
// Drop animation state
|
||||
let dropping = false;
|
||||
let dropCol = -1;
|
||||
let dropPlayer = 0;
|
||||
let dropTargetRow = -1;
|
||||
let dropY = 0;
|
||||
let dropTargetY = 0;
|
||||
|
||||
// --- Board helpers ------------------------------------------
|
||||
function makeBoard() {
|
||||
const b = [];
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
b[c] = new Array(ROWS).fill(0);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
board = makeBoard();
|
||||
winnerPlayer = 0;
|
||||
winPositions = [];
|
||||
currentMoves = "";
|
||||
}
|
||||
|
||||
function getFirstEmptyRow(b, col) {
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
if (b[col][r] === 0) return r;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isBoardFull(b) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
if (b[c][ROWS - 1] === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function scanBoard(b) {
|
||||
function check(c, r, dc, dr) {
|
||||
const p = b[c][r];
|
||||
if (p === 0) return [0, []];
|
||||
const pos = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const cc = c + i * dc;
|
||||
const rr = r + i * dr;
|
||||
if (cc < 0 || cc >= COLS || rr < 0 || rr >= ROWS) return [0, []];
|
||||
if (b[cc][rr] !== p) return [0, []];
|
||||
pos.push([cc, rr]);
|
||||
}
|
||||
return [p, pos];
|
||||
}
|
||||
|
||||
for (let r = 0; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++) {
|
||||
const [w, pos] = check(c, r, 1, 0);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const [w, pos] = check(c, r, 0, 1);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++) {
|
||||
const [w, pos] = check(c, r, 1, 1);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
for (let r = 3; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++) {
|
||||
const [w, pos] = check(c, r, 1, -1);
|
||||
if (w) return [w, pos];
|
||||
}
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
function evaluateBoard(b, aiP, huP) {
|
||||
let score = 0;
|
||||
|
||||
// Center column bonus
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
if (b[3][r] === aiP) score += 3;
|
||||
else if (b[3][r] === huP) score -= 3;
|
||||
}
|
||||
|
||||
// Score a window of 4 cells by piece counts
|
||||
function scoreWindow(c, r, dc, dr) {
|
||||
let ai = 0, hu = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const v = b[c + i * dc][r + i * dr];
|
||||
if (v === aiP) ai++;
|
||||
else if (v === huP) hu++;
|
||||
}
|
||||
if (ai > 0 && hu > 0) return 0;
|
||||
if (ai === 3) return 50;
|
||||
if (ai === 2) return 5;
|
||||
if (hu === 3) return -50;
|
||||
if (hu === 2) return -5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
for (let r = 0; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++)
|
||||
score += scoreWindow(c, r, 1, 0);
|
||||
// Vertical
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c < COLS; c++)
|
||||
score += scoreWindow(c, r, 0, 1);
|
||||
// Diagonal up-right
|
||||
for (let r = 0; r <= ROWS - 4; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++)
|
||||
score += scoreWindow(c, r, 1, 1);
|
||||
// Diagonal down-right
|
||||
for (let r = 3; r < ROWS; r++)
|
||||
for (let c = 0; c <= COLS - 4; c++)
|
||||
score += scoreWindow(c, r, 1, -1);
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
// --- AI -----------------------------------------------------
|
||||
function minimax(b, depth, alpha, beta, isMax, aiP, huP) {
|
||||
const [winner] = scanBoard(b);
|
||||
if (winner === aiP) return 1000 + depth;
|
||||
if (winner === huP) return -1000 - depth;
|
||||
if (depth === 0 || isBoardFull(b)) return evaluateBoard(b, aiP, huP);
|
||||
|
||||
let best = isMax ? -10000 : 10000;
|
||||
for (const c of COL_ORDER) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = isMax ? aiP : huP;
|
||||
const score = minimax(b, depth - 1, alpha, beta, !isMax, aiP, huP);
|
||||
b[c][r] = 0;
|
||||
if (isMax) {
|
||||
if (score > best) best = score;
|
||||
if (best > alpha) alpha = best;
|
||||
} else {
|
||||
if (score < best) best = score;
|
||||
if (best < beta) beta = best;
|
||||
}
|
||||
if (beta <= alpha) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function performAiMove(b, aiP, lookAhead, isDemo = false, dPly = 4) {
|
||||
const huP = aiP === 1 ? 2 : 1;
|
||||
const ply = isDemo ? dPly : lookAhead;
|
||||
|
||||
// Phase 1a: check ALL columns for instant AI win
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = aiP;
|
||||
if (scanBoard(b)[0] === aiP) { b[c][r] = 0; return c; }
|
||||
b[c][r] = 0;
|
||||
}
|
||||
|
||||
// Phase 1b: check ALL columns for opponent block
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = huP;
|
||||
if (scanBoard(b)[0] === huP) { b[c][r] = 0; return c; }
|
||||
b[c][r] = 0;
|
||||
}
|
||||
|
||||
// Phase 2: blunder
|
||||
if (!isDemo && BLUNDER_ENABLED && Math.random() * 100 < BLUNDER_CHANCE) {
|
||||
const valid = [];
|
||||
for (let c = 0; c < COLS; c++) if (getFirstEmptyRow(b, c) !== -1) valid.push(c);
|
||||
return valid[Math.floor(Math.random() * valid.length)];
|
||||
}
|
||||
|
||||
// Phase 3: minimax
|
||||
let bestScore = -30000;
|
||||
let bestCol = 3;
|
||||
for (const c of COL_ORDER) {
|
||||
const r = getFirstEmptyRow(b, c);
|
||||
if (r === -1) continue;
|
||||
b[c][r] = aiP;
|
||||
const score = minimax(b, ply, -30000, 30000, false, aiP, huP);
|
||||
b[c][r] = 0;
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestCol = c;
|
||||
}
|
||||
}
|
||||
return bestCol;
|
||||
}
|
||||
|
||||
function randomizeDemoPlies() {
|
||||
const strong = 4 + Math.floor(Math.random() * 2);
|
||||
const weak = 2 + Math.floor(Math.random() * 2);
|
||||
return Math.random() < 0.5 ? [strong, weak] : [weak, strong];
|
||||
}
|
||||
|
||||
// --- Game log (localStorage) --------------------------------
|
||||
function loadGameLog() {
|
||||
try {
|
||||
const raw = localStorage.getItem("connectFourLog");
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw).slice(-MAX_GAME_LOG);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveGameLog(g) {
|
||||
try {
|
||||
localStorage.setItem("connectFourLog", JSON.stringify(g.slice(-MAX_GAME_LOG)));
|
||||
} catch { /* storage full or unavailable */ }
|
||||
}
|
||||
|
||||
function logGame(g, gMenuMode, level, winner, moves) {
|
||||
const type = gMenuMode === 0 ? "Y" : gMenuMode === 1 ? "R" : "2";
|
||||
const winChar = winner === 1 ? "Y" : winner === 2 ? "R" : "D";
|
||||
g.push({ type, level: String(level), winner: winChar, moves });
|
||||
g = g.slice(-MAX_GAME_LOG);
|
||||
saveGameLog(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
// --- Check game end -----------------------------------------
|
||||
function checkGameEnd() {
|
||||
const [w, pos] = scanBoard(board);
|
||||
winnerPlayer = w;
|
||||
winPositions = pos;
|
||||
const won = w !== 0;
|
||||
const draw = !won && isBoardFull(board);
|
||||
if (!won && !draw) return false;
|
||||
|
||||
if (gameState !== State.DEMO) {
|
||||
games = logGame(games, gameMenuMode, gameLevel, won ? w : 0, currentMoves);
|
||||
}
|
||||
gameState = won ? State.FINISHED_WIN : State.FINISHED_DRAW;
|
||||
demoResetTimer = performance.now() / 1000;
|
||||
lastActivity = performance.now() / 1000;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Drawing ------------------------------------------------
|
||||
function playerColor(p) { return p === 1 ? COLOR_P1 : COLOR_P2; }
|
||||
function playerColorDim(p) { return p === 1 ? COLOR_P1_DIM : COLOR_P2_DIM; }
|
||||
function playerName(p) { return p === 1 ? "Yellow" : "Red"; }
|
||||
|
||||
function cellX(c) { return BOARD_PAD_X + c * CELL_SIZE + CELL_SIZE / 2; }
|
||||
function cellY(r) { return BOARD_PAD_TOP + (ROWS - 1 - r) * CELL_SIZE + CELL_SIZE / 2; }
|
||||
|
||||
function isWinPos(c, r) {
|
||||
for (const [wc, wr] of winPositions) {
|
||||
if (wc === c && wr === r) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function drawDisc(x, y, radius, color) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
function drawBoard() {
|
||||
// Board background
|
||||
ctx.fillStyle = COLOR_BOARD;
|
||||
const bx = BOARD_PAD_X;
|
||||
const by = BOARD_PAD_TOP;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bx - 5, by - 5, BOARD_W + 10, BOARD_H + 10, 12);
|
||||
ctx.fill();
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = COLOR_GRID_LINE;
|
||||
ctx.lineWidth = 1;
|
||||
for (let c = 1; c < COLS; c++) {
|
||||
const x = BOARD_PAD_X + c * CELL_SIZE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, BOARD_PAD_TOP);
|
||||
ctx.lineTo(x, BOARD_PAD_TOP + BOARD_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let r = 1; r < ROWS; r++) {
|
||||
const y = BOARD_PAD_TOP + r * CELL_SIZE;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(BOARD_PAD_X, y);
|
||||
ctx.lineTo(BOARD_PAD_X + BOARD_W, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Cells
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const x = cellX(c);
|
||||
const y = cellY(r);
|
||||
const val = board[c][r];
|
||||
|
||||
// Skip drawing in cell if we're animating a drop into it
|
||||
if (dropping && c === dropCol && r === dropTargetRow) continue;
|
||||
|
||||
if (val === 0) {
|
||||
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
|
||||
} else {
|
||||
const isWin = isWinPos(c, r);
|
||||
if (gameState === State.FINISHED_WIN) {
|
||||
if (isWin && flashToggle) {
|
||||
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
|
||||
} else if (!isWin) {
|
||||
drawDisc(x, y, DISC_RADIUS, playerColorDim(val));
|
||||
} else {
|
||||
drawDisc(x, y, DISC_RADIUS, playerColor(val));
|
||||
}
|
||||
} else if (gameState === State.FINISHED_DRAW && flashToggle) {
|
||||
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
|
||||
} else {
|
||||
drawDisc(x, y, DISC_RADIUS, playerColor(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drop animation disc
|
||||
if (dropping) {
|
||||
drawDisc(cellX(dropCol), dropY, DISC_RADIUS, playerColor(dropPlayer));
|
||||
}
|
||||
}
|
||||
|
||||
function drawCursor() {
|
||||
if (gameState === State.PLAYING && !dropping) {
|
||||
const x = cellX(activeCol);
|
||||
const y = BOARD_PAD_TOP - 45;
|
||||
drawDisc(x, y, DISC_RADIUS * 0.8, playerColor(currentPlayer));
|
||||
}
|
||||
if (gameState === State.PLAYING && hoverCol >= 0 && hoverCol !== activeCol && !dropping) {
|
||||
const x = cellX(hoverCol);
|
||||
const y = BOARD_PAD_TOP - 45;
|
||||
drawDisc(x, y, DISC_RADIUS * 0.5, playerColorDim(currentPlayer));
|
||||
}
|
||||
}
|
||||
|
||||
function drawColNumbers() {
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
ctx.fillStyle = (c === activeCol && gameState === State.PLAYING) ? COLOR_TEXT : COLOR_TEXT_DIM;
|
||||
ctx.fillText(String(c + 1), cellX(c), BOARD_PAD_TOP - 12);
|
||||
}
|
||||
}
|
||||
|
||||
function drawStatus() {
|
||||
const y = BOARD_PAD_TOP + BOARD_H + 25;
|
||||
ctx.font = `bold 18px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
if (gameState === State.PLAYING) {
|
||||
ctx.fillStyle = playerColor(currentPlayer);
|
||||
const label = gameMenuMode === 2 ? `${playerName(currentPlayer)}'s turn`
|
||||
: currentPlayer === (gameMenuMode === 0 ? 1 : 2) ? "Your turn" : "AI thinking...";
|
||||
ctx.fillText(label, CANVAS_W / 2, y);
|
||||
} else if (gameState === State.AI_TURN) {
|
||||
const aiP = gameMenuMode === 0 ? 2 : 1;
|
||||
ctx.fillStyle = playerColor(aiP);
|
||||
ctx.fillText("AI thinking...", CANVAS_W / 2, y);
|
||||
} else if (gameState === State.FINISHED_WIN) {
|
||||
ctx.fillStyle = playerColor(winnerPlayer);
|
||||
ctx.fillText(`${playerName(winnerPlayer)} wins!`, CANVAS_W / 2, y);
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.fillText("Click or press any key for menu", CANVAS_W / 2, y + 24);
|
||||
} else if (gameState === State.FINISHED_DRAW) {
|
||||
ctx.fillStyle = COLOR_TEXT;
|
||||
ctx.fillText("Draw!", CANVAS_W / 2, y);
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.fillText("Click or press any key for menu", CANVAS_W / 2, y + 24);
|
||||
} else if (gameState === State.DEMO) {
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.fillText("Demo mode - click or press any key for menu", CANVAS_W / 2, y);
|
||||
}
|
||||
}
|
||||
|
||||
function drawMenu() {
|
||||
ctx.fillStyle = COLOR_MENU_BG;
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
ctx.font = `bold 36px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = COLOR_P1;
|
||||
ctx.fillText("Connect", CANVAS_W / 2 - 60, 80);
|
||||
ctx.fillStyle = COLOR_P2;
|
||||
ctx.fillText("Four", CANVAS_W / 2 + 70, 80);
|
||||
|
||||
const items = [
|
||||
{ label: "1P Yellow (you start)", color: COLOR_P1 },
|
||||
{ label: "1P Red (AI starts)", color: COLOR_P2 },
|
||||
{ label: "Multiplayer", color: "#5dade2" },
|
||||
];
|
||||
|
||||
const startY = 160;
|
||||
const itemH = 60;
|
||||
const itemW = 340;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const y = startY + i * itemH;
|
||||
const x = (CANVAS_W - itemW) / 2;
|
||||
const selected = i === menuMode;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = selected ? COLOR_MENU_SEL : "transparent";
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, itemW, 48, 8);
|
||||
ctx.fill();
|
||||
|
||||
// Border for selected
|
||||
if (selected) {
|
||||
ctx.strokeStyle = items[i].color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, itemW, 48, 8);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Arrow
|
||||
ctx.font = `bold 20px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = selected ? items[i].color : COLOR_TEXT_DIM;
|
||||
ctx.fillText(selected ? "\u25b6 " : " ", x + 16, y + 24);
|
||||
|
||||
// Label
|
||||
ctx.font = `${selected ? "bold " : ""}18px ${FONT_FAMILY}`;
|
||||
ctx.fillText(items[i].label, x + 50, y + 24);
|
||||
}
|
||||
|
||||
// Instructions
|
||||
ctx.font = `14px ${FONT_FAMILY}`;
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillStyle = COLOR_TEXT_DIM;
|
||||
ctx.fillText("Up/Down or hover to select, click or Enter to start", CANVAS_W / 2, startY + items.length * itemH + 20);
|
||||
ctx.fillText("During game: Arrow keys or click columns, 1-7 for direct drop", CANVAS_W / 2, startY + items.length * itemH + 44);
|
||||
}
|
||||
|
||||
function render() {
|
||||
ctx.fillStyle = COLOR_BG;
|
||||
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
if (gameState === State.MENU) {
|
||||
drawMenu();
|
||||
} else {
|
||||
drawBoard();
|
||||
drawCursor();
|
||||
drawColNumbers();
|
||||
drawStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drop animation -----------------------------------------
|
||||
function animateDrop(col, row, player) {
|
||||
return new Promise(resolve => {
|
||||
dropping = true;
|
||||
dropCol = col;
|
||||
dropPlayer = player;
|
||||
dropTargetRow = row;
|
||||
dropY = BOARD_PAD_TOP - 45;
|
||||
dropTargetY = cellY(row);
|
||||
|
||||
function step(timestamp) {
|
||||
dropY += ANIM_DROP_SPEED * (1 / 60);
|
||||
if (dropY >= dropTargetY) {
|
||||
dropY = dropTargetY;
|
||||
dropping = false;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Input: column from mouse / touch -----------------------
|
||||
function colFromEvent(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = CANVAS_W / rect.width;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const col = Math.floor((x - BOARD_PAD_X) / CELL_SIZE);
|
||||
return (col >= 0 && col < COLS) ? col : -1;
|
||||
}
|
||||
|
||||
function menuItemFromEvent(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleY = CANVAS_H / rect.height;
|
||||
const scaleX = CANVAS_W / rect.width;
|
||||
const y = (e.clientY - rect.top) * scaleY;
|
||||
const x = (e.clientX - rect.left) * scaleX;
|
||||
const startY = 160;
|
||||
const itemH = 60;
|
||||
const itemW = 340;
|
||||
const mx = (CANVAS_W - itemW) / 2;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const iy = startY + i * itemH;
|
||||
if (x >= mx && x <= mx + itemW && y >= iy && y <= iy + 48) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// --- Place a disc (with animation) --------------------------
|
||||
let busy = false; // prevents input during animation / AI
|
||||
|
||||
async function placeDisk(col, player) {
|
||||
const r = getFirstEmptyRow(board, col);
|
||||
if (r === -1) return false;
|
||||
currentMoves += String(col);
|
||||
await animateDrop(col, r, player);
|
||||
board[col][r] = player;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- AI turn (async to not block UI) ------------------------
|
||||
async function doAiTurn() {
|
||||
busy = true;
|
||||
const aiP = gameMenuMode === 0 ? 2 : 1;
|
||||
gameState = State.AI_TURN;
|
||||
|
||||
// Yield a frame so "AI thinking" shows
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const bestCol = performAiMove(board, aiP, LOOK_AHEAD);
|
||||
await placeDisk(bestCol, aiP);
|
||||
activeCol = bestCol;
|
||||
|
||||
if (!checkGameEnd()) {
|
||||
gameState = State.PLAYING;
|
||||
currentPlayer = aiP === 1 ? 2 : 1;
|
||||
}
|
||||
lastActivity = performance.now() / 1000;
|
||||
busy = false;
|
||||
}
|
||||
|
||||
// --- Demo turn ----------------------------------------------
|
||||
let demoTimer = null;
|
||||
|
||||
function stopDemo() {
|
||||
if (demoTimer !== null) {
|
||||
clearTimeout(demoTimer);
|
||||
demoTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function demoStep() {
|
||||
if (gameState !== State.DEMO) return;
|
||||
busy = true;
|
||||
const ply = demoPly[currentPlayer - 1];
|
||||
const bestCol = performAiMove(board, currentPlayer, LOOK_AHEAD, true, ply);
|
||||
await placeDisk(bestCol, currentPlayer);
|
||||
|
||||
if (!checkGameEnd()) {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
demoTimer = setTimeout(demoStep, 400);
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
|
||||
function startDemo() {
|
||||
resetGame();
|
||||
demoPly = randomizeDemoPlies();
|
||||
gameState = State.DEMO;
|
||||
currentPlayer = 1;
|
||||
lastActivity = performance.now() / 1000;
|
||||
demoTimer = setTimeout(demoStep, 400);
|
||||
}
|
||||
|
||||
// --- Start game from menu -----------------------------------
|
||||
function startGame(mode) {
|
||||
resetGame();
|
||||
gameMenuMode = mode;
|
||||
gameLevel = LOOK_AHEAD;
|
||||
currentPlayer = 1;
|
||||
activeCol = 3;
|
||||
hoverCol = -1;
|
||||
|
||||
if (mode === 1) {
|
||||
gameState = State.PLAYING; // briefly, then AI
|
||||
doAiTurn();
|
||||
} else {
|
||||
gameState = State.PLAYING;
|
||||
}
|
||||
lastActivity = performance.now() / 1000;
|
||||
}
|
||||
|
||||
function returnToMenu() {
|
||||
stopDemo();
|
||||
resetGame();
|
||||
gameState = State.MENU;
|
||||
menuMode = 0;
|
||||
lastActivity = performance.now() / 1000;
|
||||
}
|
||||
|
||||
// --- Mouse events -------------------------------------------
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (gameState === State.MENU) {
|
||||
const mi = menuItemFromEvent(e);
|
||||
if (mi >= 0) menuMode = mi;
|
||||
} else if (gameState === State.PLAYING && !busy) {
|
||||
hoverCol = colFromEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener("click", async (e) => {
|
||||
if (busy) return;
|
||||
lastActivity = performance.now() / 1000;
|
||||
|
||||
if (gameState === State.MENU) {
|
||||
const mi = menuItemFromEvent(e);
|
||||
if (mi >= 0) {
|
||||
menuMode = mi;
|
||||
startGame(mi);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW || gameState === State.DEMO) {
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.PLAYING) {
|
||||
const col = colFromEvent(e);
|
||||
if (col < 0) return;
|
||||
const r = getFirstEmptyRow(board, col);
|
||||
if (r === -1) return;
|
||||
|
||||
busy = true;
|
||||
activeCol = col;
|
||||
await placeDisk(col, currentPlayer);
|
||||
|
||||
if (!checkGameEnd()) {
|
||||
if (gameMenuMode < 2) {
|
||||
await doAiTurn();
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Touch support (mobile) ---------------------------------
|
||||
canvas.addEventListener("touchend", (e) => {
|
||||
if (e.changedTouches.length > 0) {
|
||||
const touch = e.changedTouches[0];
|
||||
const click = new MouseEvent("click", {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
});
|
||||
canvas.dispatchEvent(click);
|
||||
}
|
||||
e.preventDefault();
|
||||
}, { passive: false });
|
||||
|
||||
// --- Keyboard events ----------------------------------------
|
||||
document.addEventListener("keydown", async (e) => {
|
||||
if (busy) return;
|
||||
lastActivity = performance.now() / 1000;
|
||||
|
||||
if (e.key === "q" || e.key === "Q") {
|
||||
if (gameState !== State.MENU) {
|
||||
returnToMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.MENU) {
|
||||
if (e.key === "ArrowUp") {
|
||||
menuMode = (menuMode - 1 + 3) % 3;
|
||||
} else if (e.key === "ArrowDown") {
|
||||
menuMode = (menuMode + 1) % 3;
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
startGame(menuMode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW || gameState === State.DEMO) {
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameState === State.PLAYING) {
|
||||
if (e.key === "ArrowLeft") {
|
||||
activeCol = Math.max(0, activeCol - 1);
|
||||
} else if (e.key === "ArrowRight") {
|
||||
activeCol = Math.min(COLS - 1, activeCol + 1);
|
||||
} else if (e.key >= "1" && e.key <= "7") {
|
||||
const col = parseInt(e.key) - 1;
|
||||
const r = getFirstEmptyRow(board, col);
|
||||
if (r === -1) return;
|
||||
|
||||
busy = true;
|
||||
activeCol = col;
|
||||
await placeDisk(col, currentPlayer);
|
||||
if (!checkGameEnd()) {
|
||||
if (gameMenuMode < 2) {
|
||||
await doAiTurn();
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
const r = getFirstEmptyRow(board, activeCol);
|
||||
if (r === -1) return;
|
||||
|
||||
busy = true;
|
||||
await placeDisk(activeCol, currentPlayer);
|
||||
if (!checkGameEnd()) {
|
||||
if (gameMenuMode < 2) {
|
||||
await doAiTurn();
|
||||
} else {
|
||||
currentPlayer = currentPlayer === 1 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Main loop ----------------------------------------------
|
||||
let lastTime = 0;
|
||||
|
||||
function gameLoop(timestamp) {
|
||||
const now = timestamp / 1000;
|
||||
|
||||
// Flash toggle for win/draw
|
||||
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW) {
|
||||
if (now - lastFlash > 0.4) {
|
||||
lastFlash = now;
|
||||
flashToggle = !flashToggle;
|
||||
}
|
||||
|
||||
// Auto-restart to demo
|
||||
if (now - demoResetTimer > DEMO_RESET_PAUSE) {
|
||||
startDemo();
|
||||
}
|
||||
}
|
||||
|
||||
// Idle timeout -> demo
|
||||
if (gameState !== State.DEMO && gameState !== State.FINISHED_WIN && gameState !== State.FINISHED_DRAW) {
|
||||
if (now - lastActivity > IDLE_TIMEOUT) {
|
||||
startDemo();
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
@@ -0,0 +1,19 @@
|
||||
[env:rpipico2w]
|
||||
platform = https://github.com/maxgerhardt/platform-raspberrypi.git
|
||||
board = rpipico2w
|
||||
framework = arduino
|
||||
board_build.core = earlephilhower
|
||||
monitor_speed = 115200
|
||||
|
||||
lib_deps =
|
||||
adafruit/Adafruit ST7735 and ST7789 Library@^1.10.4
|
||||
adafruit/Adafruit GFX Library@^1.11.10
|
||||
|
||||
build_flags =
|
||||
; --- Game settings ---
|
||||
-D DEFAULT_LOOK_AHEAD=8
|
||||
-D DEFAULT_IDLE_TIMEOUT=60
|
||||
-D DEMO_RESET_PAUSE=20000
|
||||
-D MAX_GAME_LOG=100
|
||||
-D BLUNDER_ENABLED=0
|
||||
-D BLUNDER_CHANCE=20
|
||||
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
// --- Hardware pins (PicoResTouch-LCD-2.8) ---
|
||||
#define LCD_CS 9
|
||||
#define LCD_DC 8
|
||||
#define LCD_RST 15
|
||||
#define LCD_BL 13
|
||||
#define TCH_CS 16
|
||||
#define TCH_IRQ 17
|
||||
|
||||
// --- Layout (portrait 240x320) ---
|
||||
const int SCREEN_W = 240;
|
||||
const int SCREEN_H = 320;
|
||||
const int COLS = 7;
|
||||
const int ROWS = 6;
|
||||
const int CELL = 32;
|
||||
const int DISC_R = 13;
|
||||
const int BRD_X = 8; // (240 - 7*32) / 2
|
||||
const int BRD_Y = 56;
|
||||
const int STAT_Y = 254;
|
||||
const int SSTAT_Y = 278;
|
||||
|
||||
// --- Colors (RGB565, matching JS canvas palette) ---
|
||||
#define C565(r, g, b) (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))
|
||||
|
||||
const uint16_t C_BG = C565(26, 26, 46);
|
||||
const uint16_t C_BOARD = C565(22, 33, 62);
|
||||
const uint16_t C_GRID = C565(15, 52, 96);
|
||||
const uint16_t C_EMPTY = C565(10, 22, 40);
|
||||
const uint16_t C_P1 = C565(255, 215, 0);
|
||||
const uint16_t C_P2 = C565(230, 57, 70);
|
||||
const uint16_t C_P1DIM = C565(100, 85, 0);
|
||||
const uint16_t C_P2DIM = C565(90, 25, 30);
|
||||
const uint16_t C_TEXT = C565(224, 224, 224);
|
||||
const uint16_t C_TXTDIM = C565(102, 102, 128);
|
||||
const uint16_t C_MENUSEL = C565(15, 52, 96);
|
||||
const uint16_t C_MULTI = C565(93, 173, 226);
|
||||
|
||||
// --- Game defaults (overridable via build flags) ---
|
||||
#ifndef DEFAULT_LOOK_AHEAD
|
||||
#define DEFAULT_LOOK_AHEAD 8
|
||||
#endif
|
||||
#ifndef DEFAULT_IDLE_TIMEOUT
|
||||
#define DEFAULT_IDLE_TIMEOUT 60
|
||||
#endif
|
||||
#ifndef DEMO_RESET_PAUSE
|
||||
#define DEMO_RESET_PAUSE 20000
|
||||
#endif
|
||||
#ifndef MAX_GAME_LOG
|
||||
#define MAX_GAME_LOG 100
|
||||
#endif
|
||||
#ifndef BLUNDER_ENABLED
|
||||
#define BLUNDER_ENABLED 0
|
||||
#endif
|
||||
#ifndef BLUNDER_CHANCE
|
||||
#define BLUNDER_CHANCE 20
|
||||
#endif
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
#include "display.h"
|
||||
#include "config.h"
|
||||
#include "game.h"
|
||||
#include "storage.h"
|
||||
|
||||
// --- Text helpers ---
|
||||
|
||||
void drawTextCenter(int16_t cx, int16_t cy, const char *text,
|
||||
uint16_t fg, uint8_t size)
|
||||
{
|
||||
tft.setTextSize(size);
|
||||
tft.setTextColor(fg);
|
||||
int16_t bx, by;
|
||||
uint16_t bw, bh;
|
||||
tft.getTextBounds(text, 0, 0, &bx, &by, &bw, &bh);
|
||||
tft.setCursor(cx - bw / 2 - bx, cy - bh / 2 - by);
|
||||
tft.print(text);
|
||||
}
|
||||
|
||||
static void drawButton(int16_t x, int16_t y, int16_t w, int16_t h,
|
||||
const char *label, uint16_t fg, uint16_t bg)
|
||||
{
|
||||
tft.fillRoundRect(x, y, w, h, 6, bg);
|
||||
tft.drawRoundRect(x, y, w, h, 6, fg);
|
||||
drawTextCenter(x + w / 2, y + h / 2, label, fg, 2);
|
||||
}
|
||||
|
||||
// --- Game board ---
|
||||
|
||||
void drawCell(int c, int r)
|
||||
{
|
||||
int cx = cellX(c), cy = cellY(r);
|
||||
int8_t v = board[c][r];
|
||||
|
||||
if (v == 0)
|
||||
{
|
||||
tft.fillCircle(cx, cy, DISC_R, C_EMPTY);
|
||||
return;
|
||||
}
|
||||
if (gameState == FINISHED_WIN)
|
||||
{
|
||||
if (isWinPos(c, r))
|
||||
tft.fillCircle(cx, cy, DISC_R, flashToggle ? C_EMPTY : playerColor(v));
|
||||
else
|
||||
tft.fillCircle(cx, cy, DISC_R, playerColorDim(v));
|
||||
}
|
||||
else if (gameState == FINISHED_DRAW)
|
||||
{
|
||||
tft.fillCircle(cx, cy, DISC_R, flashToggle ? C_EMPTY : playerColor(v));
|
||||
}
|
||||
else
|
||||
{
|
||||
tft.fillCircle(cx, cy, DISC_R, playerColor(v));
|
||||
}
|
||||
}
|
||||
|
||||
void drawBoardFull()
|
||||
{
|
||||
tft.fillRoundRect(BRD_X - 4, BRD_Y - 4,
|
||||
COLS * CELL + 8, ROWS * CELL + 8, 6, C_BOARD);
|
||||
for (int c = 1; c < COLS; c++)
|
||||
tft.drawFastVLine(BRD_X + c * CELL, BRD_Y, ROWS * CELL, C_GRID);
|
||||
for (int r = 1; r < ROWS; r++)
|
||||
tft.drawFastHLine(BRD_X, BRD_Y + r * CELL, COLS * CELL, C_GRID);
|
||||
for (int c = 0; c < COLS; c++)
|
||||
for (int r = 0; r < ROWS; r++)
|
||||
drawCell(c, r);
|
||||
}
|
||||
|
||||
void drawColNumbers()
|
||||
{
|
||||
tft.setTextSize(1);
|
||||
tft.setTextColor(C_TXTDIM, C_BG);
|
||||
for (int c = 0; c < COLS; c++)
|
||||
{
|
||||
tft.setCursor(cellX(c) - 3, BRD_Y - 12);
|
||||
tft.print(c + 1);
|
||||
}
|
||||
}
|
||||
|
||||
void drawStatus(const char *text, uint16_t color)
|
||||
{
|
||||
tft.fillRect(0, STAT_Y - 2, 240, 20, C_BG);
|
||||
drawTextCenter(120, STAT_Y + 7, text, color, 2);
|
||||
}
|
||||
|
||||
void drawSubStatus(const char *text)
|
||||
{
|
||||
tft.fillRect(0, SSTAT_Y - 2, 240, 16, C_BG);
|
||||
drawTextCenter(120, SSTAT_Y + 5, text, C_TXTDIM, 1);
|
||||
}
|
||||
|
||||
void drawGameStatus()
|
||||
{
|
||||
if (gameState == PLAYING)
|
||||
{
|
||||
if (gameMenuMode == 2)
|
||||
{
|
||||
drawStatus(currentPlayer == 1 ? "Yellow's turn" : "Red's turn",
|
||||
playerColor(currentPlayer));
|
||||
}
|
||||
else
|
||||
{
|
||||
int humanP = (gameMenuMode == 0) ? 1 : 2;
|
||||
drawStatus(currentPlayer == humanP ? "Your turn" : "AI thinking...",
|
||||
playerColor(currentPlayer));
|
||||
}
|
||||
drawSubStatus("");
|
||||
}
|
||||
else if (gameState == AI_TURN)
|
||||
{
|
||||
int8_t aiP = (gameMenuMode == 0) ? 2 : 1;
|
||||
drawStatus("AI thinking...", playerColor(aiP));
|
||||
drawSubStatus("");
|
||||
}
|
||||
else if (gameState == FINISHED_WIN)
|
||||
{
|
||||
drawStatus(winnerPlayer == 1 ? "Yellow wins!" : "Red wins!",
|
||||
playerColor(winnerPlayer));
|
||||
drawSubStatus("Touch for menu");
|
||||
}
|
||||
else if (gameState == FINISHED_DRAW)
|
||||
{
|
||||
drawStatus("Draw!", C_TEXT);
|
||||
drawSubStatus("Touch for menu");
|
||||
}
|
||||
else if (gameState == DEMO)
|
||||
{
|
||||
drawStatus("Demo mode", C_TXTDIM);
|
||||
drawSubStatus("Touch for menu");
|
||||
}
|
||||
}
|
||||
|
||||
void drawGameScreen()
|
||||
{
|
||||
tft.fillScreen(C_BG);
|
||||
drawBoardFull();
|
||||
drawColNumbers();
|
||||
drawGameStatus();
|
||||
}
|
||||
|
||||
// --- Menu ---
|
||||
|
||||
void drawMenu()
|
||||
{
|
||||
tft.fillScreen(C_BG);
|
||||
|
||||
tft.setTextSize(3);
|
||||
tft.setTextColor(C_P1);
|
||||
tft.setCursor(18, 18);
|
||||
tft.print("Connect");
|
||||
tft.setTextColor(C_P2);
|
||||
tft.setCursor(148, 18);
|
||||
tft.print("Four");
|
||||
|
||||
const char *labels[] = {"Play as Yellow", "Play as Red", "Two Players", "Settings"};
|
||||
uint16_t colors[] = {C_P1, C_P2, C_MULTI, C_TXTDIM};
|
||||
int itemW = 220, itemH = 36, mx = 10, startY = 80, gap = 6;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
int iy = startY + i * (itemH + gap);
|
||||
bool sel = (i == menuMode);
|
||||
|
||||
if (sel)
|
||||
{
|
||||
tft.fillRoundRect(mx, iy, itemW, itemH, 6, C_MENUSEL);
|
||||
tft.drawRoundRect(mx, iy, itemW, itemH, 6, colors[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
tft.fillRoundRect(mx, iy, itemW, itemH, 6, C_BG);
|
||||
}
|
||||
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(sel ? colors[i] : C_TXTDIM);
|
||||
int16_t bx, by;
|
||||
uint16_t bw, bh;
|
||||
tft.getTextBounds(labels[i], 0, 0, &bx, &by, &bw, &bh);
|
||||
tft.setCursor(mx + (itemW - bw) / 2 - bx, iy + (itemH - bh) / 2 - by);
|
||||
tft.print(labels[i]);
|
||||
}
|
||||
|
||||
drawTextCenter(120, startY + 4 * (itemH + gap) + 8,
|
||||
"Touch to select", C_TXTDIM, 1);
|
||||
}
|
||||
|
||||
// --- Animation ---
|
||||
|
||||
void animateDrop(int col, int player)
|
||||
{
|
||||
int targetRow = getFirstEmptyRow(col);
|
||||
if (targetRow == -1)
|
||||
return;
|
||||
if (gameState != DEMO)
|
||||
currentMoves += String(col);
|
||||
|
||||
uint16_t color = playerColor(player);
|
||||
for (int r = ROWS - 1; r >= targetRow; r--)
|
||||
{
|
||||
int cx = cellX(col), cy = cellY(r);
|
||||
tft.fillCircle(cx, cy, DISC_R, color);
|
||||
delay(max(10, 60 - (ROWS - 1 - r) * 10));
|
||||
if (r > targetRow)
|
||||
tft.fillCircle(cx, cy, DISC_R, C_EMPTY);
|
||||
}
|
||||
board[col][targetRow] = player;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Settings screen
|
||||
// ================================================================
|
||||
|
||||
static void drawSettingsValue(int y, const char *label, const char *value)
|
||||
{
|
||||
tft.fillRect(0, y, 240, 32, C_BG);
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(C_TEXT);
|
||||
tft.setCursor(10, y + 8);
|
||||
tft.print(label);
|
||||
|
||||
// [-] button
|
||||
tft.fillRoundRect(140, y, 30, 28, 4, C_MENUSEL);
|
||||
tft.drawRoundRect(140, y, 30, 28, 4, C_TXTDIM);
|
||||
drawTextCenter(155, y + 14, "-", C_TEXT, 2);
|
||||
|
||||
// Value
|
||||
tft.fillRect(174, y, 36, 28, C_BG);
|
||||
drawTextCenter(192, y + 14, value, C_P1, 2);
|
||||
|
||||
// [+] button
|
||||
tft.fillRoundRect(214, y, 30, 28, 4, C_MENUSEL);
|
||||
tft.drawRoundRect(214, y, 30, 28, 4, C_TXTDIM);
|
||||
drawTextCenter(229, y + 14, "+", C_TEXT, 2);
|
||||
}
|
||||
|
||||
static void drawSettingsToggle(int y, const char *label, bool on)
|
||||
{
|
||||
tft.fillRect(0, y, 240, 32, C_BG);
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(C_TEXT);
|
||||
tft.setCursor(10, y + 8);
|
||||
tft.print(label);
|
||||
|
||||
uint16_t col = on ? C565(40, 167, 69) : C565(120, 50, 50);
|
||||
tft.fillRoundRect(150, y, 70, 28, 4, col);
|
||||
tft.drawRoundRect(150, y, 70, 28, 4, C_TXTDIM);
|
||||
drawTextCenter(185, y + 14, on ? "ON" : "OFF", C_TEXT, 2);
|
||||
}
|
||||
|
||||
void drawSettings()
|
||||
{
|
||||
tft.fillScreen(C_BG);
|
||||
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(C_TEXT);
|
||||
tft.setCursor(70, 10);
|
||||
tft.print("Settings");
|
||||
tft.drawFastHLine(10, 34, 220, C_GRID);
|
||||
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%d", currentLookAhead);
|
||||
drawSettingsValue(50, "AI Ply:", buf);
|
||||
|
||||
drawSettingsToggle(90, "Blunder:", blunderEnabled);
|
||||
|
||||
snprintf(buf, sizeof(buf), "%d%%", blunderChance);
|
||||
drawSettingsValue(130, "Bl. Pct:", buf);
|
||||
|
||||
drawButton(10, 180, 220, 34, "View Game Log", C_MULTI, C_MENUSEL);
|
||||
drawButton(10, 222, 220, 34, "Recalibrate", C_TXTDIM, C_MENUSEL);
|
||||
drawButton(10, 274, 220, 34, "Back", C_TEXT, C_MENUSEL);
|
||||
}
|
||||
|
||||
int handleSettingsTouch(uint16_t x, uint16_t y)
|
||||
{
|
||||
// AI Ply +/-
|
||||
if (y >= 50 && y < 82)
|
||||
{
|
||||
if (x >= 140 && x < 170)
|
||||
{
|
||||
if (currentLookAhead > 1)
|
||||
currentLookAhead--;
|
||||
drawSettings();
|
||||
}
|
||||
if (x >= 214 && x < 244)
|
||||
{
|
||||
if (currentLookAhead < 10)
|
||||
currentLookAhead++;
|
||||
drawSettings();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Blunder toggle
|
||||
if (y >= 90 && y < 122 && x >= 150 && x < 220)
|
||||
{
|
||||
blunderEnabled = !blunderEnabled;
|
||||
drawSettings();
|
||||
return 0;
|
||||
}
|
||||
// Blunder % +/-
|
||||
if (y >= 130 && y < 162)
|
||||
{
|
||||
if (x >= 140 && x < 170)
|
||||
{
|
||||
if (blunderChance > 5)
|
||||
blunderChance -= 5;
|
||||
drawSettings();
|
||||
}
|
||||
if (x >= 214 && x < 244)
|
||||
{
|
||||
if (blunderChance < 100)
|
||||
blunderChance += 5;
|
||||
drawSettings();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// View Game Log
|
||||
if (y >= 180 && y < 214 && x >= 10 && x <= 230)
|
||||
return 2;
|
||||
// Recalibrate
|
||||
if (y >= 222 && y < 256 && x >= 10 && x <= 230)
|
||||
return 3;
|
||||
// Back
|
||||
if (y >= 274 && y < 308 && x >= 10 && x <= 230)
|
||||
{
|
||||
saveSettings();
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Game log screen
|
||||
// ================================================================
|
||||
|
||||
static int logPage = 0;
|
||||
static const int LOG_PER_PAGE = 12;
|
||||
static bool confirmClear = false;
|
||||
|
||||
void drawGameLogScreen()
|
||||
{
|
||||
tft.fillScreen(C_BG);
|
||||
confirmClear = false;
|
||||
|
||||
// Title
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(C_TEXT);
|
||||
tft.setCursor(10, 6);
|
||||
tft.print("Game Log (");
|
||||
tft.print(gameLogCount);
|
||||
tft.print(")");
|
||||
tft.drawFastHLine(10, 28, 220, C_GRID);
|
||||
|
||||
if (gameLogCount == 0)
|
||||
{
|
||||
drawTextCenter(120, 150, "No games yet", C_TXTDIM, 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Header
|
||||
tft.setTextSize(1);
|
||||
tft.setTextColor(C_TXTDIM);
|
||||
tft.setCursor(6, 34);
|
||||
tft.print(" # Typ Lv Win Moves");
|
||||
|
||||
// Entries (newest first)
|
||||
int totalPages = (gameLogCount + LOG_PER_PAGE - 1) / LOG_PER_PAGE;
|
||||
if (logPage >= totalPages)
|
||||
logPage = totalPages - 1;
|
||||
if (logPage < 0)
|
||||
logPage = 0;
|
||||
|
||||
int startIdx = gameLogCount - 1 - logPage * LOG_PER_PAGE;
|
||||
|
||||
for (int row = 0; row < LOG_PER_PAGE && startIdx - row >= 0; row++)
|
||||
{
|
||||
int idx = startIdx - row;
|
||||
int ry = 48 + row * 18;
|
||||
bool playerWon = gameLog[idx].type != '2' &&
|
||||
gameLog[idx].type == gameLog[idx].winner;
|
||||
|
||||
tft.setTextSize(1);
|
||||
tft.setTextColor(playerWon ? C_P2 : C_TEXT);
|
||||
tft.setCursor(6, ry);
|
||||
|
||||
char line[40];
|
||||
snprintf(line, sizeof(line), "%3d %c %2d %c %s",
|
||||
idx + 1, gameLog[idx].type, gameLog[idx].level,
|
||||
gameLog[idx].winner,
|
||||
gameLog[idx].moves.substring(0, 18).c_str());
|
||||
tft.print(line);
|
||||
}
|
||||
|
||||
// Page indicator
|
||||
tft.setTextSize(1);
|
||||
tft.setTextColor(C_TXTDIM);
|
||||
char pgBuf[16];
|
||||
snprintf(pgBuf, sizeof(pgBuf), "Page %d/%d", logPage + 1, totalPages);
|
||||
drawTextCenter(120, 275, pgBuf, C_TXTDIM, 1);
|
||||
}
|
||||
|
||||
// Bottom buttons
|
||||
int btnY = 286;
|
||||
if (gameLogCount > LOG_PER_PAGE)
|
||||
drawButton(10, btnY, 60, 28, "Prev", C_TXTDIM, C_MENUSEL);
|
||||
drawButton(90, btnY, 60, 28, "Clear", C_P2, C_MENUSEL);
|
||||
if (gameLogCount > LOG_PER_PAGE)
|
||||
drawButton(170, btnY, 60, 28, "Next", C_TXTDIM, C_MENUSEL);
|
||||
|
||||
drawButton(60, 318 - 30, 120, 28, "Back", C_TEXT, C_MENUSEL);
|
||||
}
|
||||
|
||||
int handleGameLogTouch(uint16_t x, uint16_t y)
|
||||
{
|
||||
int btnY = 286;
|
||||
|
||||
if (confirmClear)
|
||||
{
|
||||
// Confirmation row
|
||||
if (y >= 150 && y < 186)
|
||||
{
|
||||
if (x >= 40 && x < 110)
|
||||
{
|
||||
clearGameLog();
|
||||
confirmClear = false;
|
||||
drawGameLogScreen();
|
||||
}
|
||||
if (x >= 130 && x < 200)
|
||||
{
|
||||
confirmClear = false;
|
||||
drawGameLogScreen();
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Prev
|
||||
if (y >= btnY && y < btnY + 28 && x >= 10 && x < 70)
|
||||
{
|
||||
if (logPage > 0)
|
||||
{
|
||||
logPage--;
|
||||
drawGameLogScreen();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Clear
|
||||
if (y >= btnY && y < btnY + 28 && x >= 90 && x < 150)
|
||||
{
|
||||
confirmClear = true;
|
||||
tft.fillRect(20, 140, 200, 50, C_BG);
|
||||
tft.drawRoundRect(20, 140, 200, 50, 6, C_P2);
|
||||
drawTextCenter(120, 150, "Clear all?", C_P2, 2);
|
||||
drawButton(40, 160, 70, 24, "Yes", C_P2, C_MENUSEL);
|
||||
drawButton(130, 160, 70, 24, "No", C_TEXT, C_MENUSEL);
|
||||
return 0;
|
||||
}
|
||||
// Next
|
||||
if (y >= btnY && y < btnY + 28 && x >= 170 && x < 230)
|
||||
{
|
||||
int totalPages = (gameLogCount + LOG_PER_PAGE - 1) / LOG_PER_PAGE;
|
||||
if (logPage < totalPages - 1)
|
||||
{
|
||||
logPage++;
|
||||
drawGameLogScreen();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
// Back
|
||||
if (y >= 288 && y < 318 && x >= 60 && x < 180)
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
// --- Text helpers ---
|
||||
void drawTextCenter(int16_t cx, int16_t cy, const char *text, uint16_t fg, uint8_t size);
|
||||
|
||||
// --- Game board drawing ---
|
||||
void drawCell(int c, int r);
|
||||
void drawBoardFull();
|
||||
void drawColNumbers();
|
||||
void drawStatus(const char *text, uint16_t color);
|
||||
void drawSubStatus(const char *text);
|
||||
void drawGameStatus();
|
||||
void drawGameScreen();
|
||||
void drawMenu();
|
||||
void animateDrop(int col, int player);
|
||||
|
||||
// --- Settings UI ---
|
||||
void drawSettings();
|
||||
int handleSettingsTouch(uint16_t x, uint16_t y); // returns: 0=handled, 1=back, 2=gamelog, 3=recalibrate
|
||||
|
||||
// --- Game log UI ---
|
||||
void drawGameLogScreen();
|
||||
int handleGameLogTouch(uint16_t x, uint16_t y); // returns: 0=handled, 1=back
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
#include "game.h"
|
||||
#include "touch.h"
|
||||
#include "storage.h"
|
||||
|
||||
// --- Global definitions ---
|
||||
|
||||
Adafruit_ST7789 tft = Adafruit_ST7789(&SPI1, LCD_CS, LCD_DC, LCD_RST);
|
||||
|
||||
int8_t board[COLS][ROWS];
|
||||
WinPos winPos[4];
|
||||
int winCount = 0;
|
||||
|
||||
State gameState = MENU;
|
||||
int8_t menuMode = 0;
|
||||
int8_t currentPlayer = 1;
|
||||
int8_t winnerPlayer = 0;
|
||||
int8_t activeCol = 3;
|
||||
int8_t gameMenuMode = 0;
|
||||
uint8_t demoPly[2] = {4, 4};
|
||||
bool abortAi = false;
|
||||
String currentMoves;
|
||||
uint8_t gameLevel = 0;
|
||||
uint8_t currentLookAhead = DEFAULT_LOOK_AHEAD;
|
||||
bool blunderEnabled = BLUNDER_ENABLED;
|
||||
uint8_t blunderChance = BLUNDER_CHANCE;
|
||||
|
||||
GameEntry gameLog[MAX_GAME_LOG];
|
||||
uint8_t gameLogCount = 0;
|
||||
|
||||
uint32_t lastActivityTime = 0;
|
||||
uint32_t demoResetTimer = 0;
|
||||
uint32_t lastDemoMove = 0;
|
||||
bool flashToggle = true;
|
||||
uint32_t lastFlash = 0;
|
||||
bool needRedraw = true;
|
||||
|
||||
const int8_t colOrder[] = {3, 2, 4, 1, 5, 0, 6};
|
||||
|
||||
// --- Board helpers ---
|
||||
|
||||
uint16_t playerColor(int8_t p) { return p == 1 ? C_P1 : C_P2; }
|
||||
uint16_t playerColorDim(int8_t p) { return p == 1 ? C_P1DIM : C_P2DIM; }
|
||||
|
||||
int cellX(int c) { return BRD_X + c * CELL + CELL / 2; }
|
||||
int cellY(int r) { return BRD_Y + (ROWS - 1 - r) * CELL + CELL / 2; }
|
||||
|
||||
void resetBoard()
|
||||
{
|
||||
memset(board, 0, sizeof(board));
|
||||
winnerPlayer = 0;
|
||||
winCount = 0;
|
||||
}
|
||||
|
||||
int getFirstEmptyRow(int col)
|
||||
{
|
||||
for (int r = 0; r < ROWS; r++)
|
||||
if (board[col][r] == 0)
|
||||
return r;
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool isBoardFull()
|
||||
{
|
||||
for (int c = 0; c < COLS; c++)
|
||||
if (board[c][ROWS - 1] == 0)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isWinPos(int c, int r)
|
||||
{
|
||||
for (int i = 0; i < winCount; i++)
|
||||
if (winPos[i].c == c && winPos[i].r == r)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void randomizeDemoPlies()
|
||||
{
|
||||
uint8_t strong = random(4, 6), weak = random(2, 4);
|
||||
if (random(2))
|
||||
{
|
||||
demoPly[0] = strong;
|
||||
demoPly[1] = weak;
|
||||
}
|
||||
else
|
||||
{
|
||||
demoPly[0] = weak;
|
||||
demoPly[1] = strong;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Game logic ---
|
||||
|
||||
int8_t scanBoard()
|
||||
{
|
||||
winCount = 0;
|
||||
for (int r = 0; r < ROWS; r++)
|
||||
for (int c = 0; c <= COLS - 4; c++)
|
||||
{
|
||||
int8_t p = board[c][r];
|
||||
if (p && board[c + 1][r] == p && board[c + 2][r] == p && board[c + 3][r] == p)
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
winPos[i] = {(int8_t)(c + i), (int8_t)r};
|
||||
winCount = 4;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
for (int r = 0; r <= ROWS - 4; r++)
|
||||
for (int c = 0; c < COLS; c++)
|
||||
{
|
||||
int8_t p = board[c][r];
|
||||
if (p && board[c][r + 1] == p && board[c][r + 2] == p && board[c][r + 3] == p)
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
winPos[i] = {(int8_t)c, (int8_t)(r + i)};
|
||||
winCount = 4;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
for (int r = 0; r <= ROWS - 4; r++)
|
||||
for (int c = 0; c <= COLS - 4; c++)
|
||||
{
|
||||
int8_t p = board[c][r];
|
||||
if (p && board[c + 1][r + 1] == p && board[c + 2][r + 2] == p && board[c + 3][r + 3] == p)
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
winPos[i] = {(int8_t)(c + i), (int8_t)(r + i)};
|
||||
winCount = 4;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
for (int r = 3; r < ROWS; r++)
|
||||
for (int c = 0; c <= COLS - 4; c++)
|
||||
{
|
||||
int8_t p = board[c][r];
|
||||
if (p && board[c + 1][r - 1] == p && board[c + 2][r - 2] == p && board[c + 3][r - 3] == p)
|
||||
{
|
||||
for (int i = 0; i < 4; i++)
|
||||
winPos[i] = {(int8_t)(c + i), (int8_t)(r - i)};
|
||||
winCount = 4;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int evaluateBoard(int8_t aiP, int8_t huP)
|
||||
{
|
||||
int score = 0;
|
||||
for (int r = 0; r < ROWS; r++)
|
||||
{
|
||||
if (board[3][r] == aiP)
|
||||
score += 3;
|
||||
else if (board[3][r] == huP)
|
||||
score -= 3;
|
||||
}
|
||||
auto sw = [&](int c, int r, int dc, int dr) -> int
|
||||
{
|
||||
int ai = 0, hu = 0;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
int8_t v = board[c + i * dc][r + i * dr];
|
||||
if (v == aiP)
|
||||
ai++;
|
||||
else if (v == huP)
|
||||
hu++;
|
||||
}
|
||||
if (ai && hu)
|
||||
return 0;
|
||||
if (ai == 3)
|
||||
return 50;
|
||||
if (ai == 2)
|
||||
return 5;
|
||||
if (hu == 3)
|
||||
return -50;
|
||||
if (hu == 2)
|
||||
return -5;
|
||||
return 0;
|
||||
};
|
||||
for (int r = 0; r < 6; r++)
|
||||
for (int c = 0; c < 4; c++)
|
||||
score += sw(c, r, 1, 0);
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 7; c++)
|
||||
score += sw(c, r, 0, 1);
|
||||
for (int r = 0; r < 3; r++)
|
||||
for (int c = 0; c < 4; c++)
|
||||
score += sw(c, r, 1, 1);
|
||||
for (int r = 3; r < 6; r++)
|
||||
for (int c = 0; c < 4; c++)
|
||||
score += sw(c, r, 1, -1);
|
||||
return score;
|
||||
}
|
||||
|
||||
bool checkGameEnd()
|
||||
{
|
||||
winnerPlayer = scanBoard();
|
||||
bool won = winnerPlayer != 0;
|
||||
bool draw = !won && isBoardFull();
|
||||
if (!won && !draw)
|
||||
return false;
|
||||
if (gameState != DEMO)
|
||||
logGame(won ? winnerPlayer : 0);
|
||||
gameState = won ? FINISHED_WIN : FINISHED_DRAW;
|
||||
demoResetTimer = millis();
|
||||
lastActivityTime = millis();
|
||||
flashToggle = false;
|
||||
lastFlash = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- AI ---
|
||||
|
||||
int minimax(int depth, int alpha, int beta, bool isMax,
|
||||
int8_t aiP, int8_t huP)
|
||||
{
|
||||
if (gameState == DEMO && depth >= currentLookAhead - 1)
|
||||
{
|
||||
int16_t rx, ry;
|
||||
if (readRawTouch(rx, ry))
|
||||
{
|
||||
abortAi = true;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
yield();
|
||||
if (abortAi)
|
||||
return 0;
|
||||
|
||||
int8_t win = scanBoard();
|
||||
if (win == aiP)
|
||||
return 1000 + depth;
|
||||
if (win == huP)
|
||||
return -1000 - depth;
|
||||
if (depth == 0 || isBoardFull())
|
||||
return evaluateBoard(aiP, huP);
|
||||
|
||||
int best = isMax ? -10000 : 10000;
|
||||
for (int ci = 0; ci < COLS; ci++)
|
||||
{
|
||||
int c = colOrder[ci];
|
||||
if (abortAi)
|
||||
return 0;
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r == -1)
|
||||
continue;
|
||||
board[c][r] = isMax ? aiP : huP;
|
||||
int score = minimax(depth - 1, alpha, beta, !isMax, aiP, huP);
|
||||
board[c][r] = 0;
|
||||
if (isMax)
|
||||
{
|
||||
if (score > best)
|
||||
best = score;
|
||||
if (best > alpha)
|
||||
alpha = best;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (score < best)
|
||||
best = score;
|
||||
if (best < beta)
|
||||
beta = best;
|
||||
}
|
||||
if (beta <= alpha)
|
||||
break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
int computeAiMove(int8_t aiP)
|
||||
{
|
||||
abortAi = false;
|
||||
int8_t huP = (aiP == 1) ? 2 : 1;
|
||||
int bestScore = -30000, bestCol = 3;
|
||||
int originalPly = currentLookAhead;
|
||||
if (gameState == DEMO)
|
||||
currentLookAhead = demoPly[aiP - 1];
|
||||
|
||||
bool found = false;
|
||||
|
||||
for (int c = 0; c < COLS && !found; c++)
|
||||
{
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r == -1)
|
||||
continue;
|
||||
board[c][r] = aiP;
|
||||
if (scanBoard() == aiP)
|
||||
{
|
||||
board[c][r] = 0;
|
||||
bestCol = c;
|
||||
found = true;
|
||||
}
|
||||
else
|
||||
board[c][r] = 0;
|
||||
}
|
||||
|
||||
for (int c = 0; c < COLS && !found; c++)
|
||||
{
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r == -1)
|
||||
continue;
|
||||
board[c][r] = huP;
|
||||
if (scanBoard() == huP)
|
||||
{
|
||||
board[c][r] = 0;
|
||||
bestCol = c;
|
||||
found = true;
|
||||
}
|
||||
else
|
||||
board[c][r] = 0;
|
||||
}
|
||||
|
||||
if (!found && blunderEnabled && gameState != DEMO &&
|
||||
(random(100) < blunderChance))
|
||||
{
|
||||
int validCols[COLS], count = 0;
|
||||
for (int c = 0; c < COLS; c++)
|
||||
if (getFirstEmptyRow(c) != -1)
|
||||
validCols[count++] = c;
|
||||
bestCol = validCols[random(count)];
|
||||
found = true;
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
for (int ci = 0; ci < COLS; ci++)
|
||||
{
|
||||
int c = colOrder[ci];
|
||||
if (abortAi)
|
||||
break;
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r == -1)
|
||||
continue;
|
||||
board[c][r] = aiP;
|
||||
int score = minimax(currentLookAhead, -30000, 30000, false, aiP, huP);
|
||||
board[c][r] = 0;
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestCol = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentLookAhead = originalPly;
|
||||
return bestCol;
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
#include <Adafruit_ST7789.h>
|
||||
#include "config.h"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
enum State
|
||||
{
|
||||
MENU,
|
||||
PLAYING,
|
||||
AI_TURN,
|
||||
FINISHED_WIN,
|
||||
FINISHED_DRAW,
|
||||
DEMO,
|
||||
SETTINGS,
|
||||
GAME_LOG
|
||||
};
|
||||
|
||||
struct WinPos
|
||||
{
|
||||
int8_t c, r;
|
||||
};
|
||||
|
||||
struct GameEntry
|
||||
{
|
||||
char type;
|
||||
uint8_t level;
|
||||
char winner;
|
||||
String moves;
|
||||
};
|
||||
|
||||
// --- Shared globals (defined in game.cpp) ---
|
||||
|
||||
extern Adafruit_ST7789 tft;
|
||||
|
||||
extern int8_t board[COLS][ROWS];
|
||||
extern WinPos winPos[4];
|
||||
extern int winCount;
|
||||
|
||||
extern State gameState;
|
||||
extern int8_t menuMode;
|
||||
extern int8_t currentPlayer;
|
||||
extern int8_t winnerPlayer;
|
||||
extern int8_t activeCol;
|
||||
extern int8_t gameMenuMode;
|
||||
extern uint8_t demoPly[2];
|
||||
extern bool abortAi;
|
||||
extern String currentMoves;
|
||||
extern uint8_t gameLevel;
|
||||
extern uint8_t currentLookAhead;
|
||||
extern bool blunderEnabled;
|
||||
extern uint8_t blunderChance;
|
||||
|
||||
extern GameEntry gameLog[MAX_GAME_LOG];
|
||||
extern uint8_t gameLogCount;
|
||||
|
||||
extern uint32_t lastActivityTime;
|
||||
extern uint32_t demoResetTimer;
|
||||
extern uint32_t lastDemoMove;
|
||||
extern bool flashToggle;
|
||||
extern uint32_t lastFlash;
|
||||
extern bool needRedraw;
|
||||
|
||||
extern const int8_t colOrder[];
|
||||
|
||||
// --- Board helpers ---
|
||||
|
||||
uint16_t playerColor(int8_t p);
|
||||
uint16_t playerColorDim(int8_t p);
|
||||
int cellX(int c);
|
||||
int cellY(int r);
|
||||
void resetBoard();
|
||||
int getFirstEmptyRow(int col);
|
||||
bool isBoardFull();
|
||||
bool isWinPos(int c, int r);
|
||||
void randomizeDemoPlies();
|
||||
|
||||
// --- Game logic ---
|
||||
|
||||
int8_t scanBoard();
|
||||
int evaluateBoard(int8_t aiP, int8_t huP);
|
||||
bool checkGameEnd();
|
||||
|
||||
// --- AI ---
|
||||
|
||||
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP);
|
||||
int computeAiMove(int8_t aiP);
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
/* ================================================================
|
||||
* Connect Four — Pico 2W + PicoResTouch-LCD-2.8
|
||||
* Main entry: setup, loop, state dispatch
|
||||
* ================================================================ */
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <EEPROM.h>
|
||||
#include "config.h"
|
||||
#include "game.h"
|
||||
#include "touch.h"
|
||||
#include "storage.h"
|
||||
#include "display.h"
|
||||
|
||||
// --- State handlers ---
|
||||
|
||||
void startGame(int mode)
|
||||
{
|
||||
resetBoard();
|
||||
gameMenuMode = mode;
|
||||
gameLevel = currentLookAhead;
|
||||
currentPlayer = 1;
|
||||
activeCol = 3;
|
||||
currentMoves = "";
|
||||
gameState = (mode == 1) ? AI_TURN : PLAYING;
|
||||
lastActivityTime = millis();
|
||||
needRedraw = true;
|
||||
}
|
||||
|
||||
void returnToMenu()
|
||||
{
|
||||
abortAi = true;
|
||||
resetBoard();
|
||||
gameState = MENU;
|
||||
menuMode = 0;
|
||||
lastActivityTime = millis();
|
||||
needRedraw = true;
|
||||
}
|
||||
|
||||
void startDemo()
|
||||
{
|
||||
resetBoard();
|
||||
randomizeDemoPlies();
|
||||
gameState = DEMO;
|
||||
currentPlayer = 1;
|
||||
lastActivityTime = millis();
|
||||
lastDemoMove = millis();
|
||||
needRedraw = true;
|
||||
}
|
||||
|
||||
void handleAiTurn()
|
||||
{
|
||||
int8_t aiP = (gameMenuMode == 0) ? 2 : 1;
|
||||
int bestCol = computeAiMove(aiP);
|
||||
if (abortAi)
|
||||
{
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
delay(150);
|
||||
animateDrop(bestCol, aiP);
|
||||
activeCol = bestCol;
|
||||
|
||||
if (!checkGameEnd())
|
||||
{
|
||||
gameState = PLAYING;
|
||||
currentPlayer = (aiP == 1) ? 2 : 1;
|
||||
drawGameStatus();
|
||||
}
|
||||
else
|
||||
{
|
||||
drawBoardFull();
|
||||
drawGameStatus();
|
||||
}
|
||||
lastActivityTime = millis();
|
||||
}
|
||||
|
||||
void handleDemoStep()
|
||||
{
|
||||
if (millis() - lastDemoMove < 400)
|
||||
return;
|
||||
lastDemoMove = millis();
|
||||
|
||||
int bestCol = computeAiMove(currentPlayer);
|
||||
if (abortAi)
|
||||
{
|
||||
returnToMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
animateDrop(bestCol, currentPlayer);
|
||||
|
||||
if (!checkGameEnd())
|
||||
{
|
||||
currentPlayer = (currentPlayer == 1) ? 2 : 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
drawBoardFull();
|
||||
drawGameStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void handleFlash()
|
||||
{
|
||||
if (millis() - lastFlash > 400)
|
||||
{
|
||||
lastFlash = millis();
|
||||
flashToggle = !flashToggle;
|
||||
for (int c = 0; c < COLS; c++)
|
||||
for (int r = 0; r < ROWS; r++)
|
||||
if (board[c][r] != 0)
|
||||
drawCell(c, r);
|
||||
}
|
||||
if (millis() - demoResetTimer > DEMO_RESET_PAUSE)
|
||||
startDemo();
|
||||
}
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
void setup()
|
||||
{
|
||||
Serial.begin(115200);
|
||||
|
||||
analogWrite(LCD_BL, 255);
|
||||
|
||||
SPI1.setRX(12);
|
||||
SPI1.setTX(11);
|
||||
SPI1.setSCK(10);
|
||||
|
||||
pinMode(TCH_CS, OUTPUT);
|
||||
digitalWrite(TCH_CS, HIGH);
|
||||
|
||||
tft.init(240, 320);
|
||||
tft.setRotation(0);
|
||||
tft.fillScreen(C_BG);
|
||||
|
||||
initStorage();
|
||||
|
||||
bool needCal = (EEPROM.read(EEPROM_CAL) != CAL_MAGIC);
|
||||
if (!needCal)
|
||||
{
|
||||
delay(100);
|
||||
int16_t rx, ry;
|
||||
if (readRawTouch(rx, ry))
|
||||
{
|
||||
tft.fillScreen(ST77XX_BLACK);
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(ST77XX_WHITE);
|
||||
tft.setCursor(10, 140);
|
||||
tft.print("Recalibrating...");
|
||||
while (readRawTouch(rx, ry))
|
||||
delay(50);
|
||||
needCal = true;
|
||||
}
|
||||
}
|
||||
if (needCal)
|
||||
calibrateTouch();
|
||||
loadCalibration();
|
||||
|
||||
loadSettings();
|
||||
loadGameLog();
|
||||
|
||||
randomSeed(analogRead(26));
|
||||
lastActivityTime = millis();
|
||||
needRedraw = true;
|
||||
}
|
||||
|
||||
// --- Loop ---
|
||||
|
||||
void loop()
|
||||
{
|
||||
uint16_t tx, ty;
|
||||
bool touchDown = getTouchDown(tx, ty);
|
||||
|
||||
if (touchDown)
|
||||
{
|
||||
lastActivityTime = millis();
|
||||
|
||||
switch (gameState)
|
||||
{
|
||||
case MENU:
|
||||
{
|
||||
int item = menuItemFromTouch(tx, ty);
|
||||
if (item >= 0 && item < 3)
|
||||
startGame(item);
|
||||
else if (item == 3)
|
||||
{
|
||||
gameState = SETTINGS;
|
||||
needRedraw = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case PLAYING:
|
||||
{
|
||||
int col = colFromTouch(tx, ty);
|
||||
if (col >= 0)
|
||||
{
|
||||
int row = getFirstEmptyRow(col);
|
||||
if (row >= 0)
|
||||
{
|
||||
animateDrop(col, currentPlayer);
|
||||
if (!checkGameEnd())
|
||||
{
|
||||
if (gameMenuMode < 2)
|
||||
{
|
||||
gameState = AI_TURN;
|
||||
drawGameStatus();
|
||||
}
|
||||
else
|
||||
{
|
||||
currentPlayer = (currentPlayer == 1) ? 2 : 1;
|
||||
drawGameStatus();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
drawBoardFull();
|
||||
drawGameStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case SETTINGS:
|
||||
{
|
||||
int result = handleSettingsTouch(tx, ty);
|
||||
if (result == 1)
|
||||
{
|
||||
gameState = MENU;
|
||||
needRedraw = true;
|
||||
}
|
||||
else if (result == 2)
|
||||
{
|
||||
gameState = GAME_LOG;
|
||||
needRedraw = true;
|
||||
}
|
||||
else if (result == 3)
|
||||
{
|
||||
calibrateTouch();
|
||||
gameState = SETTINGS;
|
||||
needRedraw = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case GAME_LOG:
|
||||
{
|
||||
int result = handleGameLogTouch(tx, ty);
|
||||
if (result == 1)
|
||||
{
|
||||
gameState = SETTINGS;
|
||||
needRedraw = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case FINISHED_WIN:
|
||||
case FINISHED_DRAW:
|
||||
case DEMO:
|
||||
returnToMenu();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needRedraw)
|
||||
{
|
||||
needRedraw = false;
|
||||
switch (gameState)
|
||||
{
|
||||
case MENU:
|
||||
drawMenu();
|
||||
break;
|
||||
case SETTINGS:
|
||||
drawSettings();
|
||||
break;
|
||||
case GAME_LOG:
|
||||
drawGameLogScreen();
|
||||
break;
|
||||
default:
|
||||
drawGameScreen();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (gameState == AI_TURN)
|
||||
handleAiTurn();
|
||||
if (gameState == DEMO)
|
||||
handleDemoStep();
|
||||
if (gameState == FINISHED_WIN || gameState == FINISHED_DRAW)
|
||||
handleFlash();
|
||||
|
||||
if (gameState == PLAYING || gameState == MENU)
|
||||
{
|
||||
if (millis() - lastActivityTime > (uint32_t)DEFAULT_IDLE_TIMEOUT * 1000)
|
||||
startDemo();
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
#include "storage.h"
|
||||
#include "config.h"
|
||||
#include "game.h"
|
||||
#include "touch.h"
|
||||
#include <EEPROM.h>
|
||||
|
||||
void initStorage()
|
||||
{
|
||||
EEPROM.begin(EEPROM_SIZE);
|
||||
}
|
||||
|
||||
void saveCalibration()
|
||||
{
|
||||
EEPROM.write(EEPROM_CAL, CAL_MAGIC);
|
||||
EEPROM.put(EEPROM_CAL + 1, tchXMin);
|
||||
EEPROM.put(EEPROM_CAL + 3, tchXMax);
|
||||
EEPROM.put(EEPROM_CAL + 5, tchYMin);
|
||||
EEPROM.put(EEPROM_CAL + 7, tchYMax);
|
||||
EEPROM.commit();
|
||||
}
|
||||
|
||||
void loadCalibration()
|
||||
{
|
||||
if (EEPROM.read(EEPROM_CAL) != CAL_MAGIC)
|
||||
return;
|
||||
EEPROM.get(EEPROM_CAL + 1, tchXMin);
|
||||
EEPROM.get(EEPROM_CAL + 3, tchXMax);
|
||||
EEPROM.get(EEPROM_CAL + 5, tchYMin);
|
||||
EEPROM.get(EEPROM_CAL + 7, tchYMax);
|
||||
}
|
||||
|
||||
void saveSettings()
|
||||
{
|
||||
EEPROM.write(EEPROM_SETTINGS, SETTINGS_MAGIC);
|
||||
EEPROM.write(EEPROM_SETTINGS + 1, currentLookAhead);
|
||||
EEPROM.write(EEPROM_SETTINGS + 2, blunderEnabled ? 1 : 0);
|
||||
EEPROM.write(EEPROM_SETTINGS + 3, blunderChance);
|
||||
EEPROM.commit();
|
||||
}
|
||||
|
||||
void loadSettings()
|
||||
{
|
||||
if (EEPROM.read(EEPROM_SETTINGS) != SETTINGS_MAGIC)
|
||||
return;
|
||||
currentLookAhead = EEPROM.read(EEPROM_SETTINGS + 1);
|
||||
blunderEnabled = EEPROM.read(EEPROM_SETTINGS + 2) != 0;
|
||||
blunderChance = EEPROM.read(EEPROM_SETTINGS + 3);
|
||||
}
|
||||
|
||||
void saveGameLog()
|
||||
{
|
||||
EEPROM.write(EEPROM_SETTINGS + 4, gameLogCount);
|
||||
for (int i = 0; i < gameLogCount; i++)
|
||||
{
|
||||
int off = EEPROM_LOG + i * GAME_ENTRY_SIZE;
|
||||
EEPROM.write(off, gameLog[i].type);
|
||||
EEPROM.write(off + 1, gameLog[i].level);
|
||||
EEPROM.write(off + 2, gameLog[i].winner);
|
||||
uint8_t len = min((int)gameLog[i].moves.length(), 42);
|
||||
EEPROM.write(off + 3, len);
|
||||
for (int j = 0; j < len; j++)
|
||||
EEPROM.write(off + 4 + j, gameLog[i].moves[j]);
|
||||
}
|
||||
EEPROM.commit();
|
||||
}
|
||||
|
||||
void loadGameLog()
|
||||
{
|
||||
gameLogCount = EEPROM.read(EEPROM_SETTINGS + 4);
|
||||
if (gameLogCount > MAX_GAME_LOG)
|
||||
gameLogCount = 0;
|
||||
for (int i = 0; i < gameLogCount; i++)
|
||||
{
|
||||
int off = EEPROM_LOG + i * GAME_ENTRY_SIZE;
|
||||
gameLog[i].type = EEPROM.read(off);
|
||||
gameLog[i].level = EEPROM.read(off + 1);
|
||||
gameLog[i].winner = EEPROM.read(off + 2);
|
||||
uint8_t len = EEPROM.read(off + 3);
|
||||
if (len > 42)
|
||||
{
|
||||
gameLogCount = i;
|
||||
break;
|
||||
}
|
||||
gameLog[i].moves = "";
|
||||
for (int j = 0; j < len; j++)
|
||||
gameLog[i].moves += (char)EEPROM.read(off + 4 + j);
|
||||
}
|
||||
}
|
||||
|
||||
void logGame(int8_t winner)
|
||||
{
|
||||
char type = (gameMenuMode == 0) ? 'Y' : (gameMenuMode == 1) ? 'R'
|
||||
: '2';
|
||||
char winChar = (winner == 1) ? 'Y' : (winner == 2) ? 'R'
|
||||
: 'D';
|
||||
GameEntry entry = {type, gameLevel, winChar, currentMoves};
|
||||
if (gameLogCount < MAX_GAME_LOG)
|
||||
{
|
||||
gameLog[gameLogCount++] = entry;
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < MAX_GAME_LOG - 1; i++)
|
||||
gameLog[i] = gameLog[i + 1];
|
||||
gameLog[MAX_GAME_LOG - 1] = entry;
|
||||
}
|
||||
saveGameLog();
|
||||
}
|
||||
|
||||
void clearGameLog()
|
||||
{
|
||||
gameLogCount = 0;
|
||||
saveGameLog();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
#define CAL_MAGIC 0xC4
|
||||
#define SETTINGS_MAGIC 0xC5
|
||||
#define EEPROM_CAL 0
|
||||
#define EEPROM_SETTINGS 9
|
||||
#define EEPROM_LOG 16
|
||||
#define GAME_ENTRY_SIZE 46
|
||||
#define EEPROM_SIZE (EEPROM_LOG + MAX_GAME_LOG * GAME_ENTRY_SIZE)
|
||||
|
||||
void initStorage();
|
||||
void saveCalibration();
|
||||
void loadCalibration();
|
||||
void saveSettings();
|
||||
void loadSettings();
|
||||
void saveGameLog();
|
||||
void loadGameLog();
|
||||
void logGame(int8_t winner);
|
||||
void clearGameLog();
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
#include "touch.h"
|
||||
#include "config.h"
|
||||
#include "game.h"
|
||||
#include "storage.h"
|
||||
#include <SPI.h>
|
||||
|
||||
int16_t tchXMin = 130, tchXMax = 1943;
|
||||
int16_t tchYMin = 161, tchYMax = 1948;
|
||||
|
||||
static bool lastTouchDown = false;
|
||||
static uint32_t touchCooldown = 0;
|
||||
|
||||
bool readRawTouch(int16_t &rawX, int16_t &rawY)
|
||||
{
|
||||
SPI1.beginTransaction(SPISettings(2500000, MSBFIRST, SPI_MODE0));
|
||||
digitalWrite(TCH_CS, LOW);
|
||||
|
||||
SPI1.transfer(0xB1);
|
||||
int z1 = SPI1.transfer16(0) >> 3;
|
||||
SPI1.transfer(0xC1);
|
||||
int z2 = SPI1.transfer16(0) >> 3;
|
||||
int pressure = z1 + 4095 - z2;
|
||||
bool touched = pressure > 1500;
|
||||
|
||||
if (touched)
|
||||
{
|
||||
int32_t sx = 0, sy = 0;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
SPI1.transfer(0xD0);
|
||||
sx += SPI1.transfer16(0) >> 3;
|
||||
SPI1.transfer(0x90);
|
||||
sy += SPI1.transfer16(0) >> 3;
|
||||
}
|
||||
rawX = sx / 4;
|
||||
rawY = sy / 4;
|
||||
}
|
||||
|
||||
SPI1.transfer(0x00);
|
||||
digitalWrite(TCH_CS, HIGH);
|
||||
SPI1.endTransaction();
|
||||
return touched;
|
||||
}
|
||||
|
||||
bool getTouchDown(uint16_t &x, uint16_t &y)
|
||||
{
|
||||
int16_t rawX, rawY;
|
||||
bool touched = readRawTouch(rawX, rawY);
|
||||
bool down = touched && !lastTouchDown && (millis() > touchCooldown);
|
||||
lastTouchDown = touched;
|
||||
if (!down)
|
||||
return false;
|
||||
|
||||
touchCooldown = millis() + 300;
|
||||
x = constrain(map(rawX, tchXMin, tchXMax, 0, 239), 0, 239);
|
||||
y = constrain(map(rawY, tchYMin, tchYMax, 0, 319), 0, 319);
|
||||
return true;
|
||||
}
|
||||
|
||||
int colFromTouch(uint16_t x, uint16_t y)
|
||||
{
|
||||
if (y < BRD_Y - 20 || y > (uint16_t)(BRD_Y + ROWS * CELL + 10))
|
||||
return -1;
|
||||
int col = ((int)x - BRD_X) / CELL;
|
||||
return (col >= 0 && col < COLS) ? col : -1;
|
||||
}
|
||||
|
||||
int menuItemFromTouch(uint16_t x, uint16_t y)
|
||||
{
|
||||
int itemW = 220, itemH = 36, mx = 10, startY = 80, gap = 6;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
int iy = startY + i * (itemH + gap);
|
||||
if ((int)x >= mx && (int)x <= mx + itemW &&
|
||||
(int)y >= iy && (int)y <= iy + itemH)
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void calibrateTouch()
|
||||
{
|
||||
tft.fillScreen(ST77XX_BLACK);
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(ST77XX_WHITE);
|
||||
tft.setCursor(10, 140);
|
||||
tft.print("Touch top-left +");
|
||||
tft.drawFastHLine(10, 20, 20, ST77XX_MAGENTA);
|
||||
tft.drawFastVLine(20, 10, 20, ST77XX_MAGENTA);
|
||||
|
||||
int16_t rx, ry;
|
||||
while (!readRawTouch(rx, ry))
|
||||
delay(10);
|
||||
tchXMin = rx;
|
||||
tchYMin = ry;
|
||||
while (readRawTouch(rx, ry))
|
||||
delay(10);
|
||||
delay(300);
|
||||
|
||||
tft.fillScreen(ST77XX_BLACK);
|
||||
tft.setCursor(10, 140);
|
||||
tft.print("Touch bottom-right +");
|
||||
tft.drawFastHLine(210, 300, 20, ST77XX_MAGENTA);
|
||||
tft.drawFastVLine(220, 290, 20, ST77XX_MAGENTA);
|
||||
|
||||
while (!readRawTouch(rx, ry))
|
||||
delay(10);
|
||||
tchXMax = rx;
|
||||
tchYMax = ry;
|
||||
while (readRawTouch(rx, ry))
|
||||
delay(10);
|
||||
|
||||
saveCalibration();
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
extern int16_t tchXMin, tchXMax, tchYMin, tchYMax;
|
||||
|
||||
bool readRawTouch(int16_t &rawX, int16_t &rawY);
|
||||
bool getTouchDown(uint16_t &x, uint16_t &y);
|
||||
int colFromTouch(uint16_t x, uint16_t y);
|
||||
int menuItemFromTouch(uint16_t x, uint16_t y);
|
||||
void calibrateTouch();
|
||||
Reference in New Issue
Block a user