/*
Pokemon TCG Dice Helper - Wokwi OLED Simulation
This file is for Wokwi simulation only. It uses an ESP32 DevKit because
Wokwi can compile and simulate ESP32 + SSD1306 directly.
The real hardware build remains the ESP8266 Wemos D1 mini code.
Wokwi controls:
Click the yellow Action button, or press R.
Short press = roll / flip.
Hold about 2 seconds = change mode.
Hold during simulator reset = battery screen.
Wokwi wiring:
ESP32 D22 -> OLED SCL
ESP32 D21 -> OLED SDA
ESP32 VIN -> OLED VCC
ESP32 GND -> OLED GND
ESP32 D14 -> Action button -> GND
ESP32 D16 -> 220R -> LED -> GND
ESP32 D34 -> battery potentiometer SIG
*/
#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define SCREEN_ADDRESS 0x3C
#define OLED_RESET -1
#define OLED_SDA_PIN 21
#define OLED_SCL_PIN 22
#define ACTION_BUTTON_PIN 14
#define STATUS_LED_PIN 16
#define BATTERY_ADC_PIN 34
#define BUTTON_HOLD_DELAY_IN_MILLIS_UNTIL_HELD_ACTION 2000UL
#define RESET_COIN_RESULTS_AFTER_THIS_MANY_MS_OF_INACTIVITY 30000UL
#define DIM_DISPLAY_AFTER_THIS_MANY_MS_OF_INACTIVITY 60000UL
#define DEEP_SLEEP_AFTER_THIS_MANY_MS_OF_INACTIVITY 900000UL
#define MAX_BATTERY_VOLTAGE 4.2f
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
enum helper_mode
{
MODE_COIN_FLIP = 0,
MODE_D6,
MODE_2D6,
MODE_D20,
MODE_MAGIC_8_BALL,
NUMBER_OF_MODES
};
helper_mode current_mode = MODE_COIN_FLIP;
const char *magic8Answers[] =
{
"That's super effective!",
"Pikachu nods Yes!",
"Pikachu bows No!",
"Shuffle and ask again.",
"That's not very effective.",
"You are confused."
};
const byte numMagic8Answers = 6;
bool button_was_pressed = false;
bool hold_action_was_processed = false;
unsigned long button_pressed_at = 0;
unsigned long last_activity_at = 0;
bool display_dimmed = false;
bool coin_idle_reset_done = false;
bool shutdown_screen_visible = false;
int recent_heads_count = 0;
int recent_tails_count = 0;
bool coin_result_is_available = false;
bool last_coin_result_was_heads = false;
bool is_action_button_pressed()
{
return digitalRead(ACTION_BUTTON_PIN) == LOW;
}
void setup_text_to_display(int text_color, int text_size, int cursor_x, int cursor_y)
{
display.setTextColor(text_color);
display.setTextSize(text_size);
display.setCursor(cursor_x, cursor_y);
}
void drawCenteredTextLine(const String &text, int cursor_y)
{
int cursor_x = (SCREEN_WIDTH - (text.length() * 6)) / 2;
if (cursor_x < 0)
{
cursor_x = 0;
}
display.setCursor(cursor_x, cursor_y);
display.print(text);
}
void drawWrappedCenteredText(const char *text, int cursor_y, int max_chars_per_line, int line_height)
{
String remaining_text = String(text);
while (remaining_text.length() > max_chars_per_line)
{
int split_index = remaining_text.lastIndexOf(' ', max_chars_per_line);
if (split_index <= 0)
{
split_index = max_chars_per_line;
}
String line = remaining_text.substring(0, split_index);
drawCenteredTextLine(line, cursor_y);
remaining_text = remaining_text.substring(split_index);
remaining_text.trim();
cursor_y += line_height;
}
drawCenteredTextLine(remaining_text, cursor_y);
}
float get_battery_voltage()
{
int raw_value = analogRead(BATTERY_ADC_PIN);
return (raw_value / 4095.0f) * MAX_BATTERY_VOLTAGE;
}
float get_battery_percent()
{
const float battery_max = 4.20f;
const float battery_min = 3.00f;
float volts = get_battery_voltage();
float percent = ((volts - battery_min) / (battery_max - battery_min)) * 100.0f;
if (percent < 0.0f)
{
percent = 0.0f;
}
if (percent > 100.0f)
{
percent = 100.0f;
}
return percent;
}
void display_voltage()
{
float volts = get_battery_voltage();
float percent = get_battery_percent();
display.clearDisplay();
setup_text_to_display(SSD1306_WHITE, 1, 0, 0);
display.print("Battery Voltage:");
setup_text_to_display(SSD1306_WHITE, 2, 0, 24);
display.printf("%1.2f volts", volts);
setup_text_to_display(SSD1306_WHITE, 1, 0, 52);
display.printf("%1.0f %%", percent);
display.display();
}
void show_startup_battery_screen_until_button_release()
{
while (is_action_button_pressed())
{
display_voltage();
delay(250);
}
delay(50);
}
void drawNameOfDie()
{
setup_text_to_display(SSD1306_WHITE, 2, 0, 0);
switch (current_mode)
{
case MODE_COIN_FLIP:
display.print("Coin Flip");
break;
case MODE_D6:
display.print("D6");
break;
case MODE_2D6:
display.print("2D6");
break;
case MODE_D20:
display.print("D20");
break;
case MODE_MAGIC_8_BALL:
display.print("8 Ball");
break;
default:
break;
}
}
void drawShapeOfDie()
{
switch (current_mode)
{
case MODE_COIN_FLIP:
display.drawCircle(SCREEN_WIDTH / 2, 35, 20, SSD1306_WHITE);
break;
case MODE_D6:
display.drawRoundRect(40, 16, 48, 48, 5, SSD1306_WHITE);
break;
case MODE_2D6:
display.drawRoundRect(10, 16, 48, 48, 5, SSD1306_WHITE);
display.drawRoundRect(70, 16, 48, 48, 5, SSD1306_WHITE);
break;
case MODE_D20:
display.drawTriangle(64, 16, 32, 60, 96, 60, SSD1306_WHITE);
display.drawTriangle(64, 60, 32, 20, 96, 20, SSD1306_WHITE);
display.drawLine(64, 16, 64, 60, SSD1306_WHITE);
display.drawLine(32, 20, 96, 60, SSD1306_WHITE);
display.drawLine(96, 20, 32, 60, SSD1306_WHITE);
break;
case MODE_MAGIC_8_BALL:
display.drawCircle(SCREEN_WIDTH / 2, 40, 23, SSD1306_WHITE);
display.drawTriangle(46, 27, 82, 27, 64, 63, SSD1306_WHITE);
break;
default:
break;
}
}
void drawCoinFooter()
{
setup_text_to_display(SSD1306_WHITE, 1, 14, 56);
display.print("Counts H ");
display.print(recent_heads_count);
display.print(" T ");
display.print(recent_tails_count);
}
void draw_current_mode_screen()
{
display.clearDisplay();
drawNameOfDie();
drawShapeOfDie();
if (current_mode == MODE_COIN_FLIP)
{
drawCoinFooter();
}
display.display();
}
void draw_coin_screen()
{
display.clearDisplay();
drawNameOfDie();
drawShapeOfDie();
if (coin_result_is_available)
{
const char *coin_result_text = last_coin_result_was_heads ? "HEADS" : "TAILS";
int16_t text_x = 0;
int16_t text_y = 0;
uint16_t text_width = 0;
uint16_t text_height = 0;
display.setTextSize(2);
display.getTextBounds(coin_result_text, 0, 0, &text_x, &text_y, &text_width, &text_height);
int result_x = (SCREEN_WIDTH - text_width) / 2;
int result_y = 28;
display.fillRoundRect(
result_x - 4,
result_y - 3,
text_width + 8,
text_height + 6,
2,
SSD1306_BLACK
);
setup_text_to_display(
SSD1306_WHITE,
2,
result_x,
result_y
);
display.print(coin_result_text);
}
drawCoinFooter();
display.display();
}
void drawSingleD6At(int roll, int center_x)
{
const int center_y = 40;
const int offset = 15;
const int radius = 5;
if (roll == 1 || roll == 3 || roll == 5)
{
display.fillCircle(center_x, center_y, radius, SSD1306_WHITE);
}
if (roll >= 2)
{
display.fillCircle(center_x - offset, center_y + offset, radius, SSD1306_WHITE);
display.fillCircle(center_x + offset, center_y - offset, radius, SSD1306_WHITE);
}
if (roll >= 4)
{
display.fillCircle(center_x - offset, center_y - offset, radius, SSD1306_WHITE);
display.fillCircle(center_x + offset, center_y + offset, radius, SSD1306_WHITE);
}
if (roll == 6)
{
display.fillCircle(center_x - offset, center_y, radius, SSD1306_WHITE);
display.fillCircle(center_x + offset, center_y, radius, SSD1306_WHITE);
}
}
void draw6(int roll)
{
drawSingleD6At(roll, SCREEN_WIDTH / 2);
}
void draw2xD6(int roll1, int roll2)
{
drawSingleD6At(roll1, 34);
drawSingleD6At(roll2, 94);
}
void drawD20Result(int roll)
{
char roll_text[4];
int16_t text_x = 0;
int16_t text_y = 0;
uint16_t text_width = 0;
uint16_t text_height = 0;
snprintf(roll_text, sizeof(roll_text), "%d", roll);
display.setTextColor(SSD1306_WHITE);
display.setTextSize(3);
display.getTextBounds(roll_text, 0, 0, &text_x, &text_y, &text_width, &text_height);
int cursor_x = (SCREEN_WIDTH - text_width) / 2;
int cursor_y = 30;
display.fillRect(
cursor_x - 4,
cursor_y - 3,
text_width + 8,
text_height + 6,
SSD1306_BLACK
);
display.setCursor(cursor_x, cursor_y);
display.print(roll_text);
}
void drawMagic8Ball(int roll)
{
display.fillRect(4, 30, 120, 27, SSD1306_BLACK);
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
drawWrappedCenteredText(magic8Answers[roll], 32, 21, 10);
}
void restore_display_if_needed()
{
if (display_dimmed)
{
display.dim(false);
display_dimmed = false;
}
}
void mark_user_activity()
{
last_activity_at = millis();
coin_idle_reset_done = false;
shutdown_screen_visible = false;
restore_display_if_needed();
}
void process_die_change_request()
{
current_mode = (helper_mode)(current_mode + 1);
if (current_mode >= NUMBER_OF_MODES)
{
current_mode = MODE_COIN_FLIP;
}
draw_current_mode_screen();
}
void process_roll_request()
{
int roll = 0;
int roll2 = 0;
display.clearDisplay();
switch (current_mode)
{
case MODE_COIN_FLIP:
roll = random(1, 3);
last_coin_result_was_heads = (roll % 2) == 0;
coin_result_is_available = true;
if (last_coin_result_was_heads)
{
recent_heads_count++;
}
else
{
recent_tails_count++;
}
draw_coin_screen();
return;
case MODE_D6:
roll = random(1, 7);
drawNameOfDie();
drawShapeOfDie();
draw6(roll);
break;
case MODE_2D6:
roll = random(1, 7);
roll2 = random(1, 7);
drawNameOfDie();
drawShapeOfDie();
draw2xD6(roll, roll2);
break;
case MODE_D20:
roll = random(1, 21);
drawNameOfDie();
drawShapeOfDie();
drawD20Result(roll);
break;
case MODE_MAGIC_8_BALL:
roll = random(0, numMagic8Answers);
drawNameOfDie();
drawShapeOfDie();
drawMagic8Ball(roll);
break;
default:
break;
}
display.display();
}
void handle_button()
{
bool pressed_now = is_action_button_pressed();
unsigned long now = millis();
if (pressed_now && !button_was_pressed)
{
button_was_pressed = true;
hold_action_was_processed = false;
button_pressed_at = now;
mark_user_activity();
}
if (pressed_now && button_was_pressed && !hold_action_was_processed)
{
if (now - button_pressed_at >= BUTTON_HOLD_DELAY_IN_MILLIS_UNTIL_HELD_ACTION)
{
hold_action_was_processed = true;
mark_user_activity();
process_die_change_request();
}
}
if (!pressed_now && button_was_pressed)
{
button_was_pressed = false;
mark_user_activity();
if (!hold_action_was_processed)
{
process_roll_request();
}
}
}
void check_for_inactivity()
{
unsigned long now = millis();
unsigned long inactive_for = now - last_activity_at;
if (inactive_for >= RESET_COIN_RESULTS_AFTER_THIS_MANY_MS_OF_INACTIVITY && !coin_idle_reset_done)
{
recent_heads_count = 0;
recent_tails_count = 0;
coin_result_is_available = false;
last_coin_result_was_heads = false;
coin_idle_reset_done = true;
if (current_mode == MODE_COIN_FLIP)
{
draw_current_mode_screen();
}
}
if (inactive_for >= DIM_DISPLAY_AFTER_THIS_MANY_MS_OF_INACTIVITY && !display_dimmed)
{
display.dim(true);
display_dimmed = true;
}
if (inactive_for >= DEEP_SLEEP_AFTER_THIS_MANY_MS_OF_INACTIVITY && !shutdown_screen_visible)
{
digitalWrite(STATUS_LED_PIN, LOW);
display.dim(false);
display.clearDisplay();
setup_text_to_display(SSD1306_WHITE, 1, 0, 0);
display.println("Shutting Down");
display.println();
display.println("Press button");
display.println("to stop.");
display.println();
display.println("Simulation note:");
display.println("real build uses");
display.println("ESP8266 sleep.");
display.display();
shutdown_screen_visible = true;
}
}
void setup()
{
Serial.begin(115200);
pinMode(ACTION_BUTTON_PIN, INPUT_PULLUP);
pinMode(STATUS_LED_PIN, OUTPUT);
digitalWrite(STATUS_LED_PIN, HIGH);
pinMode(BATTERY_ADC_PIN, INPUT);
Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS))
{
Serial.println("Display driver failed");
for (;;)
{
delay(1000);
}
}
display.clearDisplay();
display.dim(false);
if (is_action_button_pressed())
{
show_startup_battery_screen_until_button_release();
}
display.clearDisplay();
setup_text_to_display(SSD1306_WHITE, 1, 8, 10);
display.println("Pokemon TCG");
display.setCursor(8, 24);
display.println("Dice Helper");
display.setCursor(8, 44);
display.println("Wokwi OLED sim");
display.display();
delay(1200);
randomSeed(micros());
last_activity_at = millis();
draw_current_mode_screen();
}
void loop()
{
handle_button();
check_for_inactivity();
delay(20);
}
Pokemon TCG Dice Helper
Press = roll.
Hold = mode.
Hold during reset = battery screen.
^ Press the green button to start the sim