Compare commits

...

31 Commits

Author SHA1 Message Date
zvonimir 3d75b05bf8 chore: remove libprintf 2026-04-29 01:54:16 +02:00
zvonimir 708a7d53bb feat(game+display): render animation 2026-04-29 01:48:55 +02:00
zvonimir 296b48eb9f feat(pet): add animation frame 2026-04-29 01:40:09 +02:00
zvonimir ad1c76106a fix(joystick): debounce joystick button 2026-04-29 01:33:41 +02:00
zvonimir 28af5da4f3 feat(joystick): calibrate the joystick at constructor time 2026-04-29 01:20:54 +02:00
zvonimir c6923c209c feat(game): add custom pet icon 2026-04-29 01:00:02 +02:00
zvonimir 6fffa3e879 chore(constants): extract constants into separate header 2026-04-29 00:47:59 +02:00
zvonimir ea3632326b feat(game): include pet in state 2026-04-29 00:41:37 +02:00
zvonimir 5b763a33bc feat(pet): extract pet class 2026-04-29 00:41:31 +02:00
zvonimir 6c349c2ada feat(display): allow drawing via buffer 2026-04-29 00:27:27 +02:00
zvonimir 0f807a1867 feat: add proof-of-concept 2026-04-29 00:11:35 +02:00
zvonimir d9b72aa8b5 chore: add example menu demo 2026-04-28 19:54:51 +02:00
zvonimir 075ecdf1e2 feat(display): add render method for menu items 2026-04-28 19:54:41 +02:00
zvonimir f2fbb9f205 feat(menu): add menu class 2026-04-28 19:54:32 +02:00
zvonimir 2d2051e6c2 feat(joystick): add directional input 2026-04-28 19:34:58 +02:00
zvonimir 6be5a881e7 feat(display): expose LCD member 2026-04-28 19:34:39 +02:00
zvonimir 511beae681 chore: remove joystick logging 2026-04-28 19:21:07 +02:00
zvonimir b63c610665 feat(display): increase col/row number 2026-04-28 19:18:51 +02:00
zvonimir bcb2047f60 fix(joystick): add offsets 2026-04-28 19:18:32 +02:00
zvonimir 5c9a089781 feat(display): enable lcd 2026-04-28 18:55:43 +02:00
zvonimir 1a8047d5e0 feat(display): migrate to HD44780 character lcd 2026-04-26 03:11:07 +02:00
zvonimir 48a1cd5ee6 chore: remove unused folder 2026-04-26 03:02:19 +02:00
zvonimir 85714b4520 docs: add SSD1306 display to bill of materials 2026-04-24 15:48:23 +02:00
zvonimir 924b2806fb feat(display): add display demo 2026-04-24 15:48:11 +02:00
zvonimir fb7fbfe6c4 feat(display): add display class 2026-04-24 15:46:48 +02:00
zvonimir dd643c1c10 chore: add U8g2 library 2026-04-24 15:46:32 +02:00
zvonimir 8798a2d012 docs: add ky-023 to bill of materials 2026-04-24 15:30:17 +02:00
zvonimir 79f8a70399 feat: add joystick demo 2026-04-24 15:30:10 +02:00
zvonimir 0c8d5de1c3 chore: add libprintf 2026-04-24 15:29:54 +02:00
zvonimir d5e5f031da feat(joystick): add joystick class 2026-04-24 15:29:45 +02:00
zvonimir 51cbbd35e8 chore: remove unused READMEs 2026-04-24 15:29:26 +02:00
17 changed files with 628 additions and 109 deletions
+2
View File
@@ -4,6 +4,8 @@ An open-source implementation of the Tamagotchi virtual pet game, designed to ru
## Bill of Materials
- Arduino Nano (ATmega328P)
- KY-023 Joystick Module
- HD44780 Character LCD (20x4 or 16x2, can be changed in the code)
## Development
After cloning the repository, navigate to the project directory and run `make build` to compile the code. To upload the compiled firmware to your Arduino Nano, use `make upload`.
-37
View File
@@ -1,37 +0,0 @@
This directory is intended for project header files.
A header file is a file containing C declarations and macro definitions
to be shared between several project source files. You request the use of a
header file in your project source file (C, C++, etc) located in `src` folder
by including it, with the C preprocessing directive `#include'.
```src/main.c
#include "header.h"
int main (void)
{
...
}
```
Including a header file produces the same results as copying the header file
into each source file that needs it. Such copying would be time-consuming
and error-prone. With a header file, the related declarations appear
in only one place. If they need to be changed, they can be changed in one
place, and programs that include the header file will automatically use the
new version when next recompiled. The header file eliminates the labor of
finding and changing all the copies as well as the risk that a failure to
find one copy will result in inconsistencies within a program.
In C, the convention is to give header files names that end with `.h'.
Read more about using header files in official GCC documentation:
* Include Syntax
* Include Operation
* Once-Only Headers
* Computed Includes
https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html
+15
View File
@@ -0,0 +1,15 @@
#pragma once
/*
* A header file to store all the constants used in the project.
*/
#include <Arduino.h>
#define LCD_I2C_ADDRESS 0x27
#define LCD_COLS 20
#define LCD_ROWS 4
#define ACTION_INTERVAL 60000 // 1 minute
#define MAXIMUM_STAT 100
#define ANIMATION_FRAME_INTERVAL 1500 // Time in milliseconds between animation frames
#define DEBOUNCE_DELAY 50 // Debounce delay in milliseconds for the joystick button
+25
View File
@@ -0,0 +1,25 @@
#pragma once
#include <Arduino.h>
#include <LiquidCrystal_I2C.h>
#include "constants.hpp"
#include "menu.hpp"
#include "pet.hpp"
/*
* A helper class to facilitate drawing on a HD44780 LCD display.
*/
class Display {
public:
Display();
void begin();
void clear();
void drawPetStats(Pet& pet);
void drawPet(Pet& pet);
void drawBuffer(String buffer[]);
void drawMenu(Menu& menu);
LiquidCrystal_I2C& getLCD();
private:
LiquidCrystal_I2C lcd;
};
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include "joystick.hpp"
#include "display.hpp"
#include "menu.hpp"
#include "pet.hpp"
typedef struct {
Pet pet;
uint64_t lastActionTime;
bool isMenuOpen;
bool shouldClearDisplay;
} GameState;
class Game {
public:
Game();
void begin();
void update();
void render();
protected:
void forceUpdate(String reason);
void feed();
void play();
void sleep();
void clean();
private:
GameState state;
Joystick joystick;
Display display;
String* items;
Menu menu;
};
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#include "constants.hpp"
/*
* A helper class to read values from the KY-023 joystick module.
* It provides methods to get the X and Y positions of the joystick, as well as whether the button is pressed.
* The joystick is connected to the following pins:
* - VRx (X-axis) connected to A0
* - VRy (Y-axis) connected to A1
* - SW (button) connected to D4
*
* Note: The button is active LOW and requires a pull-up resistor, which can either be external (10kΩ)
* or the internal pull-up resistor of the microcontroller (default).
*/
enum JoystickDirection {
CENTER,
UP,
DOWN,
LEFT,
RIGHT
};
class Joystick {
public:
Joystick();
double getX() const;
double getY() const;
bool isPressed();
JoystickDirection getDirection() const;
private:
class Switch {
public:
Switch(uint8_t pin);
bool isPressed();
private:
uint8_t pin;
bool lastKnownState;
bool lastStableState;
uint64_t lastDebounceTime;
};
uint8_t vrx;
uint8_t vry;
Switch sw;
uint8_t xOffset;
uint8_t yOffset;
const uint8_t deadzone = 15; // Deadzone threshold to prevent jitter around the center position
};
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <Arduino.h>
#include "constants.hpp"
#include "joystick.hpp"
class Menu {
public:
Menu();
void setItems(String* items);
bool updateCurrentItem(JoystickDirection& direction);
String& getItemAt(size_t index);
size_t getCurrentItemIndex() const;
private:
String items[LCD_ROWS];
int currentItem;
};
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <Arduino.h>
#include "constants.hpp"
class Pet {
public:
Pet();
bool isAlive;
void updateHunger(int8_t delta);
void updateJoy(int8_t delta);
void updateEnergy(int8_t delta);
void updateCleanliness(int8_t delta);
int8_t getHunger() const;
int8_t getJoy() const;
int8_t getEnergy() const;
int8_t getCleanliness() const;
String getReasonForDeath() const;
byte getAnimationFrame();
private:
int8_t hunger;
int8_t joy;
int8_t energy;
int8_t cleanliness;
String reasonForDeath;
byte lastAnimationFrame;
uint64_t lastAnimationFrameTime;
};
-46
View File
@@ -1,46 +0,0 @@
This directory is intended for project specific (private) libraries.
PlatformIO will compile them to static libraries and link into the executable file.
The source code of each library should be placed in a separate directory
("lib/your_library_name/[Code]").
For example, see the structure of the following example libraries `Foo` and `Bar`:
|--lib
| |
| |--Bar
| | |--docs
| | |--examples
| | |--src
| | |- Bar.c
| | |- Bar.h
| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html
| |
| |--Foo
| | |- Foo.c
| | |- Foo.h
| |
| |- README --> THIS FILE
|
|- platformio.ini
|--src
|- main.c
Example contents of `src/main.c` using Foo and Bar:
```
#include <Foo.h>
#include <Bar.h>
int main (void)
{
...
}
```
The PlatformIO Library Dependency Finder will find automatically dependent
libraries by scanning project source files.
More information about PlatformIO Library Dependency Finder
- https://docs.platformio.org/page/librarymanager/ldf.html
+4
View File
@@ -15,3 +15,7 @@ framework = arduino
; Set monitor baud rate
monitor_speed = 9600
; External libraries
lib_deps =
marcoschwartz/LiquidCrystal_I2C@1.1.4
+83
View File
@@ -0,0 +1,83 @@
#include "display.hpp"
// Initialize the LCD display with the specified number of columns and rows, using the I2C address.
Display::Display() : lcd(LCD_I2C_ADDRESS, LCD_COLS, LCD_ROWS) {}
void Display::begin() {
byte CUSTOM_CHAR_PET1[] = {
B00100,
B01110,
B11111,
B10101,
B11111,
B11111,
B10001,
B11111
};
byte CUSTOM_CHAR_PET2[] = {
B00100,
B01110,
B11111,
B10101,
B11111,
B10001,
B11011,
B11111
};
lcd.init();
lcd.backlight();
// Create a custom character for the pet
lcd.createChar(0, CUSTOM_CHAR_PET1);
lcd.createChar(1, CUSTOM_CHAR_PET2);
}
void Display::clear() {
lcd.clear();
}
void Display::drawBuffer(String buffer[]) {
clear();
for (size_t i = 0; i < LCD_ROWS; i++) {
lcd.setCursor(0, i);
lcd.print(buffer[i].substring(0, LCD_COLS)); // Ensure we only print up to the number of columns
}
}
void Display::drawPetStats(Pet &pet) {
clear();
lcd.setCursor(0, 0);
lcd.print("Hunger: " + String(pet.getHunger()));
lcd.setCursor(0, 1);
lcd.print("Joy: " + String(pet.getJoy()));
lcd.setCursor(0, 2);
lcd.print("Energy: " + String(pet.getEnergy()));
lcd.setCursor(0, 3);
lcd.print("Cleanliness: " + String(pet.getCleanliness()));
}
void Display::drawPet(Pet &pet) {
lcd.setCursor(LCD_COLS - 2, LCD_ROWS / 2 - 1); // Position the pet
lcd.write(pet.getAnimationFrame()); // Draw the custom pet character
}
void Display::drawMenu(Menu &menu) {
clear();
size_t currentItemIndex = menu.getCurrentItemIndex();
for (size_t i = 0; i < LCD_ROWS; i++) {
lcd.setCursor(0, i);
String item = menu.getItemAt(i);
if (i == currentItemIndex) {
lcd.print(("> " + item).substring(0, LCD_COLS)); // Add a ">" to indicate the current item
} else {
lcd.print((" " + item).substring(0, LCD_COLS)); // Indent non-selected items
}
}
}
LiquidCrystal_I2C& Display::getLCD() {
return lcd;
}
+154
View File
@@ -0,0 +1,154 @@
#include "game.hpp"
Game::Game() : joystick(), display(), menu() {
state = (GameState) {
.pet = Pet(),
.lastActionTime = 0,
.isMenuOpen = false,
.shouldClearDisplay = true,
};
String items[] = {
"Feed",
"Play",
"Sleep",
"Clean"
};
menu.setItems(items);
}
void Game::begin() {
Serial.begin(9600);
while (!Serial) {
delay(10);
}
display.begin();
}
void Game::update() {
// If the pet is dead, we don't need to update its state
if (!state.pet.isAlive) {
return;
}
bool isPressed = joystick.isPressed();
// If the menu is open, we don't need to update the pet's state
if (state.isMenuOpen) {
JoystickDirection direction = joystick.getDirection();
if (menu.updateCurrentItem(direction)) {
forceUpdate("Menu navigation");
}
// If the joystick is pressed, execute the current menu item
if (isPressed) {
state.isMenuOpen = false;
// Based on the current menu item, perform the corresponding action
switch (menu.getCurrentItemIndex()) {
case 0:
feed();
break;
case 1:
play();
break;
case 2:
sleep();
break;
case 3:
clean();
break;
}
forceUpdate("Menu action");
}
return;
}
// If the joystick is pressed and the menu is not open, open the menu
if (isPressed) {
state.isMenuOpen = true;
forceUpdate("Opening menu");
return;
}
// Update the pet's stats based on the time elapsed since the last action
uint64_t currentTime = millis();
if (currentTime - state.lastActionTime >= ACTION_INTERVAL) {
state.pet.updateHunger(10);
state.pet.updateJoy(-5);
state.pet.updateEnergy(-5);
state.pet.updateCleanliness(-5);
state.lastActionTime = currentTime;
forceUpdate("Time-based stat update");
}
}
void Game::render() {
// If the display doesn't need to be cleared, we can skip rendering
if (!state.shouldClearDisplay) {
// However, we still need to draw the pet if it's alive and
// we're not in the menu
if (state.pet.isAlive && !state.isMenuOpen) {
display.drawPet(state.pet);
}
return;
}
state.shouldClearDisplay = false;
if (state.isMenuOpen) {
display.drawMenu(menu);
Serial.println("Rendering menu");
return;
}
// If the pet is dead, display a message and return
if (!state.pet.isAlive) {
String buffer[LCD_ROWS] = {
"Your pet has died of",
state.pet.getReasonForDeath() + ".",
"Reset the device",
"to start over."
};
display.drawBuffer(buffer);
return;
}
// Render the pet's stats on the display
display.drawPetStats(state.pet);
}
void Game::forceUpdate(String reason) {
if (!state.shouldClearDisplay) {
Serial.println("Forcing update: " + reason);
state.shouldClearDisplay = true;
}
}
void Game::feed() {
if (state.pet.isAlive) {
state.pet.updateHunger(-20);
}
}
void Game::play() {
if (state.pet.isAlive) {
state.pet.updateJoy(20);
}
}
void Game::sleep() {
if (state.pet.isAlive) {
state.pet.updateEnergy(20);
}
}
void Game::clean() {
if (state.pet.isAlive) {
state.pet.updateCleanliness(20);
}
}
+68
View File
@@ -0,0 +1,68 @@
#include "joystick.hpp"
Joystick::Switch::Switch(uint8_t pin) : pin(pin), lastKnownState(HIGH), lastStableState(HIGH), lastDebounceTime(0) {
pinMode(pin, INPUT_PULLUP); // Use internal pull-up resistor
}
bool Joystick::Switch::isPressed() {
bool state = digitalRead(pin);
// Check for state change and debounce
if (state != lastKnownState) {
lastDebounceTime = millis(); // Reset debounce timer
lastKnownState = state;
}
// If the state has been stable for longer than the debounce delay, update the stable state
if ((millis() - lastDebounceTime) > DEBOUNCE_DELAY) {
if (lastStableState != state) {
lastStableState = state; // Update stable state
return state == LOW; // Return true if the button is pressed (active LOW)
}
}
return false; // No change in button state
}
// Initialize the joystick pins in the constructor
Joystick::Joystick() : vrx(A0), vry(A1), sw(Switch(4)) {
pinMode(vrx, INPUT);
pinMode(vry, INPUT);
// TODO: Calibrate the joystick by reading the center position and storing it as an offset
xOffset = 10;
yOffset = 13;
}
// Map the analog readings from the joystick to a range of -100 to 100 for both X and Y axes
double Joystick::getX() const {
return map(analogRead(vrx), 0, 1023, -100, 100) + xOffset;
}
double Joystick::getY() const {
return map(analogRead(vry), 0, 1023, -100, 100) + yOffset;
}
// Check if the joystick button is pressed (active LOW)
bool Joystick::isPressed() {
return sw.isPressed();
}
// Determine the direction of the joystick based on the X and Y values, considering a deadzone to prevent jitter
JoystickDirection Joystick::getDirection() const {
double x = getX();
double y = getY();
if (abs(x) < deadzone && abs(y) < deadzone) {
return CENTER;
} else if (y > deadzone) {
return UP;
} else if (y < -deadzone) {
return DOWN;
} else if (x > deadzone) {
return RIGHT;
} else if (x < -deadzone) {
return LEFT;
}
return CENTER; // Default to CENTER if no direction is detected
}
+7 -15
View File
@@ -1,21 +1,13 @@
#include <Arduino.h>
#include "game.hpp"
Game game;
void setup() {
Serial.begin(9600);
while (!Serial) {
delay(10);
}
pinMode(LED_BUILTIN, OUTPUT);
Serial.println("configured");
game.begin();
}
void loop() {
Serial.println("ping");
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
Serial.println("pong");
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
game.update();
delay(100); // Small delay to prevent overwhelming the CPU
game.render();
}
+31
View File
@@ -0,0 +1,31 @@
#include "menu.hpp"
Menu::Menu() : currentItem(0) {}
void Menu::setItems(String* items) {
this->currentItem = 0;
for (int i = 0; i < LCD_ROWS; i++) {
this->items[i] = items[i];
}
}
bool Menu::updateCurrentItem(JoystickDirection &direction) {
switch (direction) {
case JoystickDirection::UP:
currentItem = (currentItem - 1 + LCD_ROWS) % LCD_ROWS;
return true;
case JoystickDirection::DOWN:
currentItem = (currentItem + 1) % LCD_ROWS;
return true;
default:
return false;
}
}
String& Menu::getItemAt(size_t index) {
return items[index];
}
size_t Menu::getCurrentItemIndex() const {
return currentItem;
}
+103
View File
@@ -0,0 +1,103 @@
#include "pet.hpp"
Pet::Pet() :
isAlive(true),
hunger(0),
joy(100),
energy(100),
cleanliness(100),
reasonForDeath(""),
lastAnimationFrame(0),
lastAnimationFrameTime(0)
{}
void Pet::updateHunger(int8_t delta) {
hunger += delta;
if (hunger < 0) {
isAlive = false;
reasonForDeath = "overfeeding";
return;
}
if (hunger > MAXIMUM_STAT) {
isAlive = false;
reasonForDeath = "starvation";
}
}
void Pet::updateJoy(int8_t delta) {
joy += delta;
if (joy < 0) {
isAlive = false;
reasonForDeath = "depression";
return;
}
if (joy > MAXIMUM_STAT) {
isAlive = false;
reasonForDeath = "overstimulation";
}
}
void Pet::updateEnergy(int8_t delta) {
energy += delta;
if (energy < 0) {
isAlive = false;
reasonForDeath = "exhaustion";
return;
}
if (energy > MAXIMUM_STAT) {
isAlive = false;
reasonForDeath = "overresting";
}
}
void Pet::updateCleanliness(int8_t delta) {
cleanliness += delta;
if (cleanliness < 0) {
isAlive = false;
reasonForDeath = "disease";
return;
}
if (cleanliness > MAXIMUM_STAT) {
isAlive = false;
reasonForDeath = "overcleaning";
}
}
int8_t Pet::getHunger() const {
return hunger;
}
int8_t Pet::getJoy() const {
return joy;
}
int8_t Pet::getEnergy() const {
return energy;
}
int8_t Pet::getCleanliness() const {
return cleanliness;
}
String Pet::getReasonForDeath() const {
return reasonForDeath;
}
byte Pet::getAnimationFrame() {
uint64_t currentTime = millis();
if (currentTime - lastAnimationFrameTime >= ANIMATION_FRAME_INTERVAL) {
lastAnimationFrameTime = currentTime;
lastAnimationFrame = (lastAnimationFrame + 1) % 2; // Alternate between frame 0 and 1
}
return lastAnimationFrame;
}
-11
View File
@@ -1,11 +0,0 @@
This directory is intended for PlatformIO Test Runner and project tests.
Unit Testing is a software testing method by which individual units of
source code, sets of one or more MCU program modules together with associated
control data, usage procedures, and operating procedures, are tested to
determine whether they are fit for use. Unit testing finds problems early
in the development cycle.
More information about PlatformIO Unit Testing:
- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html