UI Updates

main
noah metz 2026-06-02 00:38:41 -06:00
parent 11e5953ccd
commit 3b527d1a8c
7 changed files with 573 additions and 102 deletions

6
.gitignore vendored

@ -1,2 +1,6 @@
case/venv/ case/venv/
case/output/*.stl case/output/
build/
pcb/.history
pcb/~*
compile_commands.json

@ -1,9 +1,10 @@
#pragma once #pragma once
// ── Buttons ─────────────────────────────────────────────────────────────────── // ── Buttons ───────────────────────────────────────────────────────────────────
#define BTN_RIGHT_GPIO 23 #define BTN_FORWARD_GPIO 23
#define BTN_LEFT_GPIO 4 #define BTN_LEFT_GPIO 4
#define BTN_MID_GPIO 5 #define BTN_RIGHT_GPIO 5
#define BTN_BACK_GPIO 19
// ── RGB LED ─────────────────────────────────────────────────────────────────── // ── RGB LED ───────────────────────────────────────────────────────────────────
#define LED_R_GPIO 13 #define LED_R_GPIO 13
@ -23,8 +24,10 @@
// ── Button timing ───────────────────────────────────────────────────────────── // ── Button timing ─────────────────────────────────────────────────────────────
#define HOLD_DELAY 50 #define HOLD_DELAY 50
#define HOLD_REPEAT 10 #define HOLD_REPEAT 10
#define MID_HOLD_MENU 15 #define COMBO_HOLD_MENU 50
#define MID_HOLD_SETTINGS 100 #define COMBO_HOLD_SETTINGS 100
#define COMBO_HOLD_SLEEP 500 // 500 × 10ms = 5s
#define SLEEP_CONTRAST 0x01
// ── BLE ─────────────────────────────────────────────────────────────────────── // ── BLE ───────────────────────────────────────────────────────────────────────
#define BLE_MFR_MAGIC_0 0xC0 #define BLE_MFR_MAGIC_0 0xC0
@ -45,7 +48,7 @@
extern const int menu_slot[NUM_MENUS]; extern const int menu_slot[NUM_MENUS];
// ── Settings ────────────────────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────────────────────
#define NUM_SETTINGS 8 #define NUM_SETTINGS 12
#define SET_BRIGHTNESS 0 #define SET_BRIGHTNESS 0
#define SET_START_LIFE 1 #define SET_START_LIFE 1
#define SET_NUM_OPP 2 #define SET_NUM_OPP 2
@ -54,6 +57,14 @@ extern const int menu_slot[NUM_MENUS];
#define SET_RESET_ALL 5 #define SET_RESET_ALL 5
#define SET_PLAYER_NAME 7 #define SET_PLAYER_NAME 7
#define SET_GAME_ID 6 #define SET_GAME_ID 6
#define SET_MENU_HOLD 8
#define SET_LR_HOLD 9
#define SET_DISPLAY_FLIP 10
#define SET_DELTA_TIMEOUT 11
#define HOLD_MS_MIN 50
#define HOLD_MS_MAX 2000
#define DELTA_TIMEOUT_MIN 100
#define DELTA_TIMEOUT_MAX 5000
#define BRIGHTNESS_MIN 1 #define BRIGHTNESS_MIN 1
#define BRIGHTNESS_MAX 100 #define BRIGHTNESS_MAX 100
#define PLAYER_NAME_LEN 8 #define PLAYER_NAME_LEN 8
@ -68,7 +79,9 @@ extern const char NAME_CHARS[];
#define NUM_HEX_CHARS 16 #define NUM_HEX_CHARS 16
// ── Counters ────────────────────────────────────────────────────────────────── // ── Counters ──────────────────────────────────────────────────────────────────
#define NUM_COUNTERS 4 #define NUM_COUNTERS 5
#define COUNTER_POISON 0
#define COUNTER_STORM 4
extern const char *counter_names[NUM_COUNTERS]; extern const char *counter_names[NUM_COUNTERS];
// ── Opponents ───────────────────────────────────────────────────────────────── // ── Opponents ─────────────────────────────────────────────────────────────────

@ -13,6 +13,7 @@ const int menu_slot[NUM_MENUS] = {0, 1, 2, 7};
const char *setting_names[NUM_SETTINGS] = { const char *setting_names[NUM_SETTINGS] = {
"LED BRIGHTNESS", "STARTING LIFE", "NUM OPPONENTS", "BLE", "LED BRIGHTNESS", "STARTING LIFE", "NUM OPPONENTS", "BLE",
"RESET", "RESET ALL", "GAME ID", "PLAYER NAME", "RESET", "RESET ALL", "GAME ID", "PLAYER NAME",
"MENU HOLD MS", "LR HOLD MS", "FLIP DISPLAY", "DELTA TIMEOUT",
}; };
#define LEDC_TIMER_SEL LEDC_TIMER_0 #define LEDC_TIMER_SEL LEDC_TIMER_0
@ -41,23 +42,34 @@ void led_init(void)
void led_update_for_count(int count, uint8_t scale) void led_update_for_count(int count, uint8_t scale)
{ {
int c = count < 0 ? 0 : count > 80 ? 80 : count; int sl = start_life_opts[g_start_life_index];
int maxl = 2 * sl;
int low = sl / 4;
int range = sl - low;
int c = count < 0 ? 0 : count > maxl ? maxl : count;
uint8_t r, g, b; uint8_t r, g, b;
if (c <= 40) { if (c <= sl) {
int cc = c < 10 ? 10 : c; int cc = c < low ? low : c;
r = (uint8_t)((uint32_t)(40-cc)*scale*g_led_max/30/255); r = (uint8_t)((uint32_t)(sl - cc) * scale * g_led_max / range / 255);
g = (uint8_t)((uint32_t)(cc-10)*scale*g_led_max/30/255); g = (uint8_t)((uint32_t)(cc - low) * scale * g_led_max / range / 255);
b = 0; b = 0;
} else { } else {
r = 0; r = 0;
g = (uint8_t)((uint32_t)(80-c)*g_led_max/40); g = (uint8_t)((uint32_t)(maxl - c) * g_led_max / sl);
b = (uint8_t)((uint32_t)(c-40)*g_led_max/40); b = (uint8_t)((uint32_t)(c - sl) * g_led_max / sl);
} }
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_0, r); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_0); ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_0, r); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_0);
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_1, g); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_1); ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_1, g); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_1);
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_2, b); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_2); ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_2, b); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_2);
} }
void led_off(void)
{
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_0, 0); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_0);
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_1, 0); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_1);
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_2, 0); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_2);
}
// ── OLED ────────────────────────────────────────────────────────────────────── // ── OLED ──────────────────────────────────────────────────────────────────────
static void oled_write(const uint8_t *buf, size_t len) static void oled_write(const uint8_t *buf, size_t len)
{ {
@ -70,17 +82,52 @@ void oled_init(void)
{ {
static const uint8_t seq[] = { static const uint8_t seq[] = {
0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,0x20,0x00, 0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,0x20,0x00,
0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,0xDB,0x40,0xA4,0xA6,0xAF, 0xA0,0xC0,0xDA,0x12,0x81,0xCF,0xD9,0xF1,0xDB,0x40,0xA4,0xA6,0xAF,
}; };
for (int i = 0; i < (int)sizeof(seq); i++) oled_cmd(seq[i]); for (int i = 0; i < (int)sizeof(seq); i++) oled_cmd(seq[i]);
} }
void oled_set_flip(int flip)
{
oled_cmd(flip ? 0xA1 : 0xA0);
oled_cmd(flip ? 0xC8 : 0xC0);
}
void oled_clear(void) void oled_clear(void)
{ {
uint8_t row[OLED_WIDTH]; memset(row, 0, sizeof(row)); uint8_t row[OLED_WIDTH]; memset(row, 0, sizeof(row));
for (int p = 0; p < OLED_PAGES; p++) oled_write_page(p, row); for (int p = 0; p < OLED_PAGES; p++) oled_write_page(p, row);
} }
void oled_set_contrast(uint8_t val)
{
oled_cmd(0x81);
oled_cmd(val);
}
void oled_draw_sleep(void)
{
// Crescent moon: outer circle (64,31) r=27, inner cutout (76,25) r=23
uint8_t row[OLED_WIDTH];
for (int p = 0; p < OLED_PAGES; p++) {
memset(row, 0, sizeof(row));
for (int x = 0; x < OLED_WIDTH; x++) {
uint8_t byte = 0;
for (int bit = 0; bit < 8; bit++) {
int y = p * 8 + bit;
int dox = x - 64, doy = y - 31;
int dix = x - 76, diy = y - 25;
if (dox*dox + doy*doy <= 27*27 &&
dix*dix + diy*diy > 23*23) {
byte |= (1 << bit);
}
}
row[x] = byte;
}
oled_write_page(p, row);
}
}
static void oled_write_page(int p, const uint8_t *row) static void oled_write_page(int p, const uint8_t *row)
{ {
oled_cmd(0x21); oled_cmd(0); oled_cmd(127); oled_cmd(0x21); oled_cmd(0); oled_cmd(127);
@ -92,7 +139,7 @@ static void oled_write_page(int p, const uint8_t *row)
void oled_draw_header(void) void oled_draw_header(void)
{ {
uint8_t pages[HEADER_PAGES][OLED_WIDTH]; uint8_t pages[HEADER_PAGES][OLED_WIDTH];
memset(pages, 0, sizeof(pages)); memset(pages, 0xFF, sizeof(pages));
for (int seg = 0; seg < NUM_MENUS; seg++) { for (int seg = 0; seg < NUM_MENUS; seg++) {
uint8_t bg = (seg == g_active_menu) ? 0x00 : 0xFF; uint8_t bg = (seg == g_active_menu) ? 0x00 : 0xFF;
int sx = menu_slot[seg] * SEG_W; int sx = menu_slot[seg] * SEG_W;
@ -177,6 +224,39 @@ static int pxbuf_str(uint8_t pages[][OLED_WIDTH], int content_h,
return x; return x;
} }
static void fill_cols(uint8_t pages[][OLED_WIDTH], int content_h,
int base, int h, int x0, int x1)
{
for (int p = base/8; p <= (base+h-1)/8 && p < content_h/8; p++) {
uint8_t mask = row_mask(p, base, h);
for (int x = x0; x < x1 && x < OLED_WIDTH; x++)
pages[p][x] |= mask;
}
}
// 5×8 mini-icons (vertical-byte, bit0=top), same format as font5x8 columns.
// Teardrop: pointed top, round bottom (for poison)
static const uint8_t icon_mini_drop[5] = {0x3C, 0x7E, 0x7F, 0x7E, 0x3C};
// Lightning bolt: zigzag (for storm)
static const uint8_t icon_mini_bolt[5] = {0x0C, 0x0E, 0x1F, 0x39, 0x70};
static int pxbuf_icon(uint8_t pages[][OLED_WIDTH], int content_h,
int base, int x, const uint8_t *cols, int width, int inv)
{
for (int sc = 0; sc < width && x < OLED_WIDTH; sc++, x++) {
for (int g = 0; g < 8; g++) {
if ((cols[sc] >> g) & 1) {
int row = base + 1 + g;
if (row < content_h) {
if (inv) pages[row/8][x] &= ~(1 << (row%8));
else pages[row/8][x] |= (1 << (row%8));
}
}
}
}
return x + 1; // 1px gap after icon
}
// ── Core list renderer ──────────────────────────────────────────────────────── // ── Core list renderer ────────────────────────────────────────────────────────
void oled_draw_rows(const oled_row_t *rows, int count, int active) void oled_draw_rows(const oled_row_t *rows, int count, int active)
@ -290,5 +370,133 @@ void oled_draw_settings(void)
rows[SET_GAME_ID].cursor = g_game_id_cursor; rows[SET_GAME_ID].cursor = g_game_id_cursor;
snprintf(rows[SET_RESET].value, sizeof(rows[0].value), "---"); snprintf(rows[SET_RESET].value, sizeof(rows[0].value), "---");
snprintf(rows[SET_RESET_ALL].value, sizeof(rows[0].value), "---"); snprintf(rows[SET_RESET_ALL].value, sizeof(rows[0].value), "---");
snprintf(rows[SET_MENU_HOLD].value, sizeof(rows[0].value), "%d", g_menu_hold_ms);
snprintf(rows[SET_LR_HOLD].value, sizeof(rows[0].value), "%d", g_lr_hold_ms);
snprintf(rows[SET_DISPLAY_FLIP].value, sizeof(rows[0].value), "%s", g_display_flip ? "ON" : "OFF");
snprintf(rows[SET_DELTA_TIMEOUT].value, sizeof(rows[0].value), "%dms", g_delta_timeout_ms);
oled_draw_rows(rows, NUM_SETTINGS, g_active_setting); oled_draw_rows(rows, NUM_SETTINGS, g_active_setting);
} }
void oled_draw_life(void)
{
const int content_pages = OLED_PAGES - HEADER_PAGES;
const int content_h = content_pages * 8;
uint8_t pages[6][OLED_WIDTH];
memset(pages, 0, sizeof(pages));
int lcp = START_PAGE - HEADER_PAGES; // content-local start page
// ── Life delta overlay (top-left, small 1x font) ──────────────────────────
if (g_life_delta != 0) {
char dbuf[8];
snprintf(dbuf, sizeof(dbuf), "%+d", g_life_delta);
pxbuf_str(pages, content_h, 0, 2, dbuf, 0);
}
if (g_eliminated) {
// ── Skull (10-col × 8-row glyph rendered at SCALE×) ──────────────────
// round cranium, hollow eyes, full cheek row, two teeth
static const uint8_t skull[10] = {
0x3C,0x62,0x21,0x79,0x21,0x21,0x79,0x21,0x62,0x3C
};
int sx = (OLED_WIDTH - 10 * SCALE) / 2;
for (int sc = 0; sc < 10 * SCALE; sc++) {
int x = sx + sc;
if (x < 0 || x >= OLED_WIDTH) continue;
uint8_t col = skull[sc / SCALE];
for (int cp = 0; cp < CHAR_PAGES; cp++) {
if (lcp + cp >= content_pages) continue;
uint8_t pixel_byte = 0;
for (int b = 0; b < 8; b++) {
int src_row = (cp * 8 + b) / SCALE;
if (src_row < 8 && ((col >> src_row) & 1))
pixel_byte |= (1 << b);
}
pages[lcp + cp][x] |= pixel_byte;
}
}
} else {
// ── Large centered life number ────────────────────────────────────────
char nbuf[12];
snprintf(nbuf, sizeof(nbuf), "%d", g_life);
int len = strlen(nbuf);
int life_x = (OLED_WIDTH - len * CHAR_WIDTH) / 2;
if (life_x < 0) life_x = 0;
for (int i = 0; i < len; i++) {
uint8_t ch = (uint8_t)nbuf[i];
if (ch < FONT_BASE || ch > FONT_MAX) ch = ' ';
const uint8_t *glyph = font5x8[ch - FONT_BASE];
int cx = life_x + i * CHAR_WIDTH;
for (int sc = 0; sc < 5 * SCALE; sc++) {
int x = cx + sc;
if (x < 0 || x >= OLED_WIDTH) continue;
uint8_t font_col = glyph[sc / SCALE];
for (int cp = 0; cp < CHAR_PAGES; cp++) {
if (lcp + cp >= content_pages) continue;
uint8_t pixel_byte = 0;
for (int b = 0; b < 8; b++) {
int src_row = (cp * 8 + b) / SCALE;
if (src_row < 8 && ((font_col >> src_row) & 1))
pixel_byte |= (1 << b);
}
pages[lcp + cp][x] |= pixel_byte;
}
}
}
}
// ── Build commander damage slot list ──────────────────────────────────────
int cmdr_slots[MAX_OPPONENTS];
int num_cmdr = 0;
if (g_ble_enabled) {
for (int i = 0; i < MAX_BLE_PEERS; i++)
if (g_peers[i].active) cmdr_slots[num_cmdr++] = i;
} else {
for (int i = 0; i < g_num_opponents; i++)
cmdr_slots[num_cmdr++] = i;
}
int total = 3 + num_cmdr;
if (g_life_select >= total) g_life_select = 0;
// ── Storm counter (bottom-left, above poison) ─────────────────────────────
{
char sbuf[8];
snprintf(sbuf, sizeof(sbuf), "%d", g_counters[COUNTER_STORM]);
int slen = strlen(sbuf);
int sbase = content_h - 18;
int ssel = (g_life_select == 1);
if (ssel) fill_cols(pages, content_h, sbase, 9, 0, 7 + slen * 6 + 2);
int sx = pxbuf_icon(pages, content_h, sbase, 2, icon_mini_bolt, 5, ssel);
pxbuf_str(pages, content_h, sbase, sx, sbuf, ssel);
}
// ── Poison counter (bottom-left, teardrop icon + number) ──────────────────
{
char pbuf[8];
snprintf(pbuf, sizeof(pbuf), "%d", g_counters[COUNTER_POISON]);
int plen = strlen(pbuf);
int pbase = content_h - 9;
int psel = (g_life_select == 2);
if (psel) fill_cols(pages, content_h, pbase, 9, 0, 7 + plen * 6 + 2);
int px = pxbuf_icon(pages, content_h, pbase, 2, icon_mini_drop, 5, psel);
pxbuf_str(pages, content_h, pbase, px, pbuf, psel);
}
// ── Commander damage (right side, stacked, 1x font) ───────────────────────
for (int si = 0; si < num_cmdr; si++) {
int val = g_cmdr_damage[cmdr_slots[si]];
char vbuf[6];
snprintf(vbuf, sizeof(vbuf), "%d", val);
int vlen = strlen(vbuf);
int cbase = si * 9;
int rx = OLED_WIDTH - 2 - vlen * 6;
int csel = (g_life_select == 3 + si);
if (csel) fill_cols(pages, content_h, cbase, 9, rx - 2, OLED_WIDTH);
pxbuf_str(pages, content_h, cbase, rx, vbuf, csel);
}
for (int p = 0; p < content_pages; p++)
oled_write_page(HEADER_PAGES + p, pages[p]);
}

@ -9,11 +9,16 @@ typedef struct {
void led_init(void); void led_init(void);
void led_update_for_count(int count, uint8_t scale); void led_update_for_count(int count, uint8_t scale);
void led_off(void);
void oled_init(void); void oled_init(void);
void oled_set_flip(int flip);
void oled_clear(void); void oled_clear(void);
void oled_set_contrast(uint8_t val);
void oled_draw_sleep(void);
void oled_draw_header(void); void oled_draw_header(void);
void oled_draw_centered(const char *str); void oled_draw_centered(const char *str);
void oled_draw_rows(const oled_row_t *rows, int count, int active); void oled_draw_rows(const oled_row_t *rows, int count, int active);
void oled_draw_list(const char **labels, const int *values, int count, int active); void oled_draw_list(const char **labels, const int *values, int count, int active);
void oled_draw_players(void); void oled_draw_players(void);
void oled_draw_settings(void); void oled_draw_settings(void);
void oled_draw_life(void);

@ -9,7 +9,7 @@
const int start_life_opts[] = {20, 30, 40}; const int start_life_opts[] = {20, 30, 40};
const char NAME_CHARS[] = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; const char NAME_CHARS[] = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const char *counter_names[NUM_COUNTERS] = {"POISON", "EXPERIENCE", "ENERGY", "LORE"}; const char *counter_names[NUM_COUNTERS] = {"POISON", "EXPERIENCE", "ENERGY", "LORE", "STORM"};
void settings_reset_defaults(void) void settings_reset_defaults(void)
{ {
@ -22,6 +22,10 @@ void settings_reset_defaults(void)
g_led_max = 26; g_led_max = 26;
g_game_id[0] = BLE_GAME_ID_0; g_game_id[0] = BLE_GAME_ID_0;
g_game_id[1] = BLE_GAME_ID_1; g_game_id[1] = BLE_GAME_ID_1;
g_menu_hold_ms = 500;
g_lr_hold_ms = 500;
g_display_flip = 0;
g_delta_timeout_ms = 1000;
} }
void game_reset(void) void game_reset(void)
@ -34,10 +38,10 @@ void game_reset(void)
void check_elimination(void) void check_elimination(void)
{ {
if (g_eliminated) return;
if (g_life <= 0 || g_counters[0] >= 10) { g_eliminated = 1; return; } if (g_life <= 0 || g_counters[0] >= 10) { g_eliminated = 1; return; }
for (int i = 0; i < g_num_opponents; i++) for (int i = 0; i < g_num_opponents; i++)
if (g_cmdr_damage[i] >= 21) { g_eliminated = 1; return; } if (g_cmdr_damage[i] >= 21) { g_eliminated = 1; return; }
g_eliminated = 0;
} }
void settings_load(void) void settings_load(void)
@ -55,9 +59,12 @@ void settings_load(void)
} }
size_t len = sizeof(g_player_name); size_t len = sizeof(g_player_name);
nvs_get_str(nvs, "pname", g_player_name, &len); nvs_get_str(nvs, "pname", g_player_name, &len);
if (nvs_get_i32(nvs, "menu_hold", &val) == ESP_OK) g_menu_hold_ms = (int)val;
if (nvs_get_i32(nvs, "lr_hold", &val) == ESP_OK) g_lr_hold_ms = (int)val;
if (nvs_get_i32(nvs, "disp_flip", &val) == ESP_OK) g_display_flip = (int)val;
if (nvs_get_i32(nvs, "delta_to", &val) == ESP_OK) g_delta_timeout_ms = (int)val;
if (nvs_get_i32(nvs, "player_id", &val) == ESP_OK) g_player_id = (uint8_t)val; if (nvs_get_i32(nvs, "player_id", &val) == ESP_OK) g_player_id = (uint8_t)val;
if (nvs_get_i32(nvs, "life", &val) == ESP_OK) g_life = (int)val; if (nvs_get_i32(nvs, "life", &val) == ESP_OK) g_life = (int)val;
if (nvs_get_i32(nvs, "elim", &val) == ESP_OK) g_eliminated = (int)val;
char key[8]; char key[8];
for (int i = 0; i < MAX_OPPONENTS; i++) { for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(key, sizeof(key), "cmdr%d", i); snprintf(key, sizeof(key), "cmdr%d", i);
@ -79,10 +86,13 @@ void settings_save(void)
nvs_set_i32(nvs, "num_opp", (int32_t)g_num_opponents); nvs_set_i32(nvs, "num_opp", (int32_t)g_num_opponents);
nvs_set_i32(nvs, "ble_en", (int32_t)g_ble_enabled); nvs_set_i32(nvs, "ble_en", (int32_t)g_ble_enabled);
nvs_set_i32(nvs, "game_id", (int32_t)((g_game_id[0] << 8) | g_game_id[1])); nvs_set_i32(nvs, "game_id", (int32_t)((g_game_id[0] << 8) | g_game_id[1]));
nvs_set_i32(nvs, "menu_hold", (int32_t)g_menu_hold_ms);
nvs_set_i32(nvs, "lr_hold", (int32_t)g_lr_hold_ms);
nvs_set_i32(nvs, "disp_flip", (int32_t)g_display_flip);
nvs_set_i32(nvs, "delta_to", (int32_t)g_delta_timeout_ms);
nvs_set_str(nvs, "pname", g_player_name); nvs_set_str(nvs, "pname", g_player_name);
nvs_set_i32(nvs, "player_id", (int32_t)g_player_id); nvs_set_i32(nvs, "player_id", (int32_t)g_player_id);
nvs_set_i32(nvs, "life", (int32_t)g_life); nvs_set_i32(nvs, "life", (int32_t)g_life);
nvs_set_i32(nvs, "elim", (int32_t)g_eliminated);
char key[8]; char key[8];
for (int i = 0; i < MAX_OPPONENTS; i++) { for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(key, sizeof(key), "cmdr%d", i); snprintf(key, sizeof(key), "cmdr%d", i);

@ -17,6 +17,8 @@
#include <math.h> #include <math.h>
// ── Global state ────────────────────────────────────────────────────────────── // ── Global state ──────────────────────────────────────────────────────────────
int g_sleep_mode = 0;
// Game // Game
int g_life; int g_life;
int g_cmdr_damage[MAX_OPPONENTS]; int g_cmdr_damage[MAX_OPPONENTS];
@ -30,6 +32,7 @@ int g_active_counter;
int g_active_player; int g_active_player;
int g_active_setting; int g_active_setting;
int g_name_cursor; int g_name_cursor;
int g_life_select;
// Settings // Settings
int g_brightness_pct = 10; int g_brightness_pct = 10;
@ -40,6 +43,9 @@ int g_ble_enabled = 0;
uint8_t g_led_max = 26; uint8_t g_led_max = 26;
int g_game_id_cursor = 0; int g_game_id_cursor = 0;
uint8_t g_game_id[2] = {BLE_GAME_ID_0, BLE_GAME_ID_1}; uint8_t g_game_id[2] = {BLE_GAME_ID_0, BLE_GAME_ID_1};
int g_menu_hold_ms = 500;
int g_lr_hold_ms = 500;
int g_display_flip = 0;
// Peers // Peers
ble_peer_t g_peers[MAX_BLE_PEERS]; ble_peer_t g_peers[MAX_BLE_PEERS];
@ -47,6 +53,11 @@ ble_peer_t g_peers[MAX_BLE_PEERS];
// Timing // Timing
uint32_t g_tick; uint32_t g_tick;
// Life delta overlay
int g_life_delta = 0;
int g_life_delta_tick = 0;
int g_delta_timeout_ms = 1000;
// ── Serial command task ─────────────────────────────────────────────────────── // ── Serial command task ───────────────────────────────────────────────────────
static void serial_print_state(void) static void serial_print_state(void)
{ {
@ -140,13 +151,16 @@ void app_main(void)
} }
g_life = start_life_opts[g_start_life_index]; g_life = start_life_opts[g_start_life_index];
settings_load(); settings_load();
check_elimination();
g_led_max = (uint8_t)(255 * g_brightness_pct / 100); g_led_max = (uint8_t)(255 * g_brightness_pct / 100);
oled_init(); oled_init();
oled_set_flip(g_display_flip);
oled_clear(); oled_clear();
gpio_config_t gpio_cfg = { gpio_config_t gpio_cfg = {
.pin_bit_mask = (1ULL<<BTN_RIGHT_GPIO)|(1ULL<<BTN_LEFT_GPIO)|(1ULL<<BTN_MID_GPIO), .pin_bit_mask = (1ULL<<BTN_FORWARD_GPIO)|(1ULL<<BTN_LEFT_GPIO)
|(1ULL<<BTN_RIGHT_GPIO)|(1ULL<<BTN_BACK_GPIO),
.mode=GPIO_MODE_INPUT, .pull_up_en=GPIO_PULLUP_ENABLE, .mode=GPIO_MODE_INPUT, .pull_up_en=GPIO_PULLUP_ENABLE,
.pull_down_en=GPIO_PULLDOWN_DISABLE, .intr_type=GPIO_INTR_DISABLE, .pull_down_en=GPIO_PULLDOWN_DISABLE, .intr_type=GPIO_INTR_DISABLE,
}; };
@ -160,39 +174,87 @@ void app_main(void)
xTaskCreate(serial_cmd_task, "serial_cmd", 4096, NULL, 3, NULL); xTaskCreate(serial_cmd_task, "serial_cmd", 4096, NULL, 3, NULL);
oled_draw_header(); oled_draw_header();
{ char ibuf[12]; snprintf(ibuf, sizeof(ibuf), "%d", g_life); oled_draw_centered(ibuf); } oled_draw_life();
led_update_for_count(g_life, 255); led_update_for_count(g_life, 255);
#ifdef DEBUG #ifdef DEBUG
serial_print_state(); serial_print_state();
#endif #endif
int prev_right=1, prev_left=1, prev_mid=1; int prev_fwd=1, prev_right=1, prev_left=1, prev_back=1;
int hold_right=0, hold_left=0, hold_mid=0, mid_fired_hold=0; int hold_fwd=0, hold_back=0, hold_right=0, hold_left=0;
int combo_tick=0, combo_fired_hold=0;
int fwd_in_combo=0, back_in_combo=0;
int fwd_menu_fired=0, back_menu_fired=0;
// Require 2 consecutive released ticks before arming hold; prevents accidental // Require 2 consecutive released ticks before arming hold; prevents accidental
// hold fires when rapid taps have sub-10ms releases that the sampler misses. // hold fires when rapid taps have sub-10ms releases that the sampler misses.
int release_r=2, release_l=2; int release_r=2, release_l=2;
int hold_armed_r=1, hold_armed_l=1; int hold_armed_r=1, hold_armed_l=1;
int players_tick=0; int players_tick=0;
int settings_dirty=0, settings_save_tick=0; int settings_dirty=0, settings_save_tick=0;
int sleep_tick=0, sleep_fired=0, ble_pre_sleep=0;
#define MARK_DIRTY() do { settings_dirty = 1; settings_save_tick = 0; } while (0) #define MARK_DIRTY() do { settings_dirty = 1; settings_save_tick = 0; } while (0)
float breath_phase=0.0f; float breath_phase=0.0f;
char nbuf[12];
char opponent_labels[MAX_OPPONENTS][PLAYER_NAME_LEN+1]; char opponent_labels[MAX_OPPONENTS][PLAYER_NAME_LEN+1];
const char *opponent_label_ptrs[MAX_OPPONENTS]; const char *opponent_label_ptrs[MAX_OPPONENTS];
while (1) { while (1) {
g_tick++; g_tick++;
int menu_ticks = g_menu_hold_ms / 10;
int lr_ticks = g_lr_hold_ms / 10;
int fwd = gpio_get_level(BTN_FORWARD_GPIO);
int right = gpio_get_level(BTN_RIGHT_GPIO); int right = gpio_get_level(BTN_RIGHT_GPIO);
int left = gpio_get_level(BTN_LEFT_GPIO); int left = gpio_get_level(BTN_LEFT_GPIO);
int mid = gpio_get_level(BTN_MID_GPIO); int back = gpio_get_level(BTN_BACK_GPIO);
int changed = 0; int changed = 0;
// ── Middle button ──────────────────────────────────────────────────── // ── Combo: all 4 buttons → sleep toggle ──────────────────────────────
if (mid == 0) { int all4 = (fwd==0 && back==0 && left==0 && right==0);
hold_mid++; if (all4) {
if (!mid_fired_hold && hold_mid == MID_HOLD_SETTINGS) { sleep_tick++;
if (!sleep_fired && sleep_tick == COMBO_HOLD_SLEEP) {
sleep_fired = 1;
hold_fwd = hold_back = hold_left = hold_right = 0;
combo_tick = 0; combo_fired_hold = 0;
if (!g_sleep_mode) {
g_sleep_mode = 1;
ble_pre_sleep = g_ble_enabled;
oled_draw_sleep();
oled_set_contrast(SLEEP_CONTRAST);
led_off();
if (g_ble_enabled) { g_ble_enabled = 0; ble_adv_update(); }
} else {
g_sleep_mode = 0;
oled_set_contrast(0xCF);
g_ble_enabled = ble_pre_sleep;
if (g_ble_enabled) ble_adv_update();
oled_draw_header();
breath_phase = 0.0f;
led_update_for_count(g_life, 255);
changed = 1;
}
}
} else {
sleep_tick = 0; sleep_fired = 0;
}
// ── Sleep mode: skip all further processing ───────────────────────────
if (g_sleep_mode) {
prev_fwd = fwd; prev_right = right; prev_left = left; prev_back = back;
vTaskDelay(pdMS_TO_TICKS(10));
continue;
}
// ── Combo: FORWARD + BACK simultaneously (settings toggle) ──────────
int both = (fwd == 0 && back == 0);
if (both) {
hold_fwd = hold_back = 0;
fwd_in_combo = back_in_combo = 1;
fwd_menu_fired = back_menu_fired = 0;
combo_tick++;
if (!combo_fired_hold && combo_tick == COMBO_HOLD_SETTINGS) {
if (g_active_menu == MENU_SETTINGS) { if (g_active_menu == MENU_SETTINGS) {
g_active_menu = 0; g_active_menu = 0;
} else { } else {
@ -201,23 +263,32 @@ void app_main(void)
g_name_cursor = 0; g_name_cursor = 0;
g_game_id_cursor = 0; g_game_id_cursor = 0;
} }
mid_fired_hold = 1; combo_fired_hold = 1;
hold_right = hold_left = players_tick = 0; hold_right = hold_left = players_tick = 0;
oled_draw_header(); oled_draw_header();
changed = 1; changed = 1;
} }
} else { } else {
if (prev_mid == 0 && !mid_fired_hold) { combo_tick = 0;
if (hold_mid >= MID_HOLD_MENU) { combo_fired_hold = 0;
if (g_active_menu == MENU_SETTINGS) {
g_active_menu = 0; // ── FORWARD: long press = next menu (+ repeat), short press = sub-item fwd ──
} else { if (fwd == 0) {
hold_fwd++;
if (g_active_menu != MENU_SETTINGS && !fwd_in_combo) {
if (hold_fwd == menu_ticks ||
(hold_fwd > menu_ticks && (hold_fwd - menu_ticks) % menu_ticks == 0)) {
g_active_menu = (g_active_menu + 1) % (NUM_MENUS - 1); g_active_menu = (g_active_menu + 1) % (NUM_MENUS - 1);
}
hold_right = hold_left = players_tick = 0; hold_right = hold_left = players_tick = 0;
oled_draw_header(); oled_draw_header();
changed = 1; changed = 1;
} else if (g_active_menu == MENU_SETTINGS) { fwd_menu_fired = 1;
}
}
} else {
if (prev_fwd == 0 && !fwd_in_combo && !fwd_menu_fired) {
if (hold_fwd < menu_ticks) {
if (g_active_menu == MENU_SETTINGS) {
if (g_active_setting == SET_PLAYER_NAME) { if (g_active_setting == SET_PLAYER_NAME) {
g_name_cursor++; g_name_cursor++;
if (g_name_cursor >= PLAYER_NAME_LEN) { if (g_name_cursor >= PLAYER_NAME_LEN) {
@ -250,40 +321,158 @@ void app_main(void)
} else if (g_active_menu == MENU_COUNTERS) { } else if (g_active_menu == MENU_COUNTERS) {
g_active_counter = (g_active_counter + 1) % NUM_COUNTERS; g_active_counter = (g_active_counter + 1) % NUM_COUNTERS;
changed = 1; changed = 1;
} else if (g_active_menu == MENU_LIFE) {
int nc = g_ble_enabled ? 0 : g_num_opponents;
if (g_ble_enabled)
for (int i = 0; i < MAX_BLE_PEERS; i++)
if (g_peers[i].active) nc++;
g_life_select = (g_life_select + 1) % (3 + nc);
changed = 1;
}
}
}
hold_fwd = 0;
fwd_in_combo = 0;
fwd_menu_fired = 0;
}
// ── BACK: long press = prev menu (+ repeat), short press = sub-item back ──
if (back == 0) {
hold_back++;
if (g_active_menu != MENU_SETTINGS && !back_in_combo) {
if (hold_back == menu_ticks ||
(hold_back > menu_ticks && (hold_back - menu_ticks) % menu_ticks == 0)) {
g_active_menu = (g_active_menu - 1 + (NUM_MENUS - 1)) % (NUM_MENUS - 1);
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
back_menu_fired = 1;
}
}
} else {
if (prev_back == 0 && !back_in_combo && !back_menu_fired) {
if (hold_back < menu_ticks) {
if (g_active_menu == MENU_SETTINGS) {
if (g_active_setting == SET_PLAYER_NAME) {
if (g_name_cursor > 0) {
g_name_cursor--;
} else {
g_active_setting = (g_active_setting - 1 + NUM_SETTINGS) % NUM_SETTINGS;
g_name_cursor = (g_active_setting == SET_PLAYER_NAME) ? PLAYER_NAME_LEN - 1 : 0;
g_game_id_cursor = (g_active_setting == SET_GAME_ID) ? GAME_ID_DIGITS - 1 : 0;
}
} else if (g_active_setting == SET_GAME_ID) {
if (g_game_id_cursor > 0) {
g_game_id_cursor--;
} else {
g_active_setting = (g_active_setting - 1 + NUM_SETTINGS) % NUM_SETTINGS;
g_name_cursor = (g_active_setting == SET_PLAYER_NAME) ? PLAYER_NAME_LEN - 1 : 0;
g_game_id_cursor = (g_active_setting == SET_GAME_ID) ? GAME_ID_DIGITS - 1 : 0;
}
} else {
g_active_setting = (g_active_setting - 1 + NUM_SETTINGS) % NUM_SETTINGS;
g_name_cursor = (g_active_setting == SET_PLAYER_NAME) ? PLAYER_NAME_LEN - 1 : 0;
g_game_id_cursor = (g_active_setting == SET_GAME_ID) ? GAME_ID_DIGITS - 1 : 0;
}
changed = 1;
} else if (g_active_menu == MENU_CMDR) {
if (g_ble_enabled) {
int total = MAX_BLE_PEERS + 1;
int next = (g_active_player - 1 + total) % total;
while (next != g_active_player && next != 0 && !g_peers[next - 1].active)
next = (next - 1 + total) % total;
g_active_player = next;
} else {
g_active_opponent = (g_active_opponent - 1 + g_num_opponents) % g_num_opponents;
}
changed = 1;
} else if (g_active_menu == MENU_COUNTERS) {
g_active_counter = (g_active_counter - 1 + NUM_COUNTERS) % NUM_COUNTERS;
changed = 1;
} else if (g_active_menu == MENU_LIFE) {
int nc = g_ble_enabled ? 0 : g_num_opponents;
if (g_ble_enabled)
for (int i = 0; i < MAX_BLE_PEERS; i++)
if (g_peers[i].active) nc++;
int total = 3 + nc;
g_life_select = (g_life_select - 1 + total) % total;
changed = 1;
}
} }
} }
hold_mid = 0; mid_fired_hold = 0; hold_back = 0;
back_in_combo = 0;
back_menu_fired = 0;
}
} }
// ── Left / Right ───────────────────────────────────────────────────── // ── delta[0]: value change (right / left) ────────────────────────────
int delta_r=0, delta_l=0; int delta_r=0, delta_l=0;
if (right==0 && left==0) {
// both held: reset counters so they stay in sync and neither fires
hold_right=0; hold_left=0; release_r=0; release_l=0;
} else {
if (right==0) { if (right==0) {
if (prev_right==1) { if (prev_right==1) {
delta_r=1; hold_right=0; delta_r=1; hold_right=0;
hold_armed_r = (release_r >= 2); hold_armed_r = (release_r >= 2); release_r=0;
release_r = 0; } else if (hold_armed_r && ++hold_right>=lr_ticks
} else if (hold_armed_r && ++hold_right>=HOLD_DELAY && (hold_right-HOLD_DELAY)%HOLD_REPEAT==0) { && (hold_right-lr_ticks)%HOLD_REPEAT==0) {
delta_r=1; delta_r=1;
} }
} else { hold_right=0; if (release_r<2) release_r++; } } else { hold_right=0; if (release_r<2) release_r++; }
if (left==0) { if (left==0) {
if (prev_left==1) { if (prev_left==1) {
delta_l=1; hold_left=0; delta_l=1; hold_left=0;
hold_armed_l = (release_l >= 2); hold_armed_l = (release_l >= 2); release_l=0;
release_l = 0; } else if (hold_armed_l && ++hold_left>=lr_ticks
} else if (hold_armed_l && ++hold_left>=HOLD_DELAY && (hold_left-HOLD_DELAY)%HOLD_REPEAT==0) { && (hold_left-lr_ticks)%HOLD_REPEAT==0) {
delta_l=1; delta_l=1;
} }
} else { hold_left=0; if (release_l<2) release_l++; } } else { hold_left=0; if (release_l<2) release_l++; }
}
if (delta_r || delta_l) { if (delta_r || delta_l) {
int d = delta_r - delta_l; int d = delta_r - delta_l;
if (g_active_menu == MENU_LIFE) { if (g_active_menu == MENU_LIFE) {
g_life += d; changed = 1; MARK_DIRTY(); check_elimination(); if (g_life_select == 0) {
g_life += d;
g_life_delta += d;
g_life_delta_tick = 0;
check_elimination();
} else if (g_life_select == 1) {
g_counters[COUNTER_STORM] += d;
if (g_counters[COUNTER_STORM] < 0) g_counters[COUNTER_STORM] = 0;
} else if (g_life_select == 2) {
g_counters[COUNTER_POISON] += d;
if (g_counters[COUNTER_POISON] < 0) g_counters[COUNTER_POISON] = 0;
check_elimination();
} else {
int si = g_life_select - 3;
if (g_ble_enabled) {
int found = 0;
for (int i = 0; i < MAX_BLE_PEERS; i++) {
if (g_peers[i].active && found++ == si) {
g_cmdr_damage[i] += d;
if (g_cmdr_damage[i] < 0) g_cmdr_damage[i] = 0;
check_elimination();
break;
}
}
} else if (si < g_num_opponents) {
g_cmdr_damage[si] += d;
if (g_cmdr_damage[si] < 0) g_cmdr_damage[si] = 0;
check_elimination();
}
}
changed = 1; MARK_DIRTY();
} else if (g_active_menu == MENU_CMDR) { } else if (g_active_menu == MENU_CMDR) {
if (g_ble_enabled) { if (g_ble_enabled) {
if (g_active_player == 0) { if (g_active_player == 0) {
g_life += d; check_elimination(); g_life += d;
g_life_delta += d;
g_life_delta_tick = 0;
check_elimination();
} else { } else {
int slot = g_active_player - 1; int slot = g_active_player - 1;
g_cmdr_damage[slot] += d; g_cmdr_damage[slot] += d;
@ -356,6 +545,25 @@ void app_main(void)
g_reset_cmd_ticks = 1500; g_reset_cmd_ticks = 1500;
ble_adv_update(); ble_adv_update();
break; break;
case SET_MENU_HOLD:
g_menu_hold_ms += d * 10;
if (g_menu_hold_ms < HOLD_MS_MIN) g_menu_hold_ms = HOLD_MS_MIN;
if (g_menu_hold_ms > HOLD_MS_MAX) g_menu_hold_ms = HOLD_MS_MAX;
break;
case SET_LR_HOLD:
g_lr_hold_ms += d * 10;
if (g_lr_hold_ms < HOLD_MS_MIN) g_lr_hold_ms = HOLD_MS_MIN;
if (g_lr_hold_ms > HOLD_MS_MAX) g_lr_hold_ms = HOLD_MS_MAX;
break;
case SET_DISPLAY_FLIP:
g_display_flip ^= 1;
oled_set_flip(g_display_flip);
break;
case SET_DELTA_TIMEOUT:
g_delta_timeout_ms += d * 100;
if (g_delta_timeout_ms < DELTA_TIMEOUT_MIN) g_delta_timeout_ms = DELTA_TIMEOUT_MIN;
if (g_delta_timeout_ms > DELTA_TIMEOUT_MAX) g_delta_timeout_ms = DELTA_TIMEOUT_MAX;
break;
} }
} }
} }
@ -380,6 +588,11 @@ void app_main(void)
printf("DBG PEER_EXPIRE slot=%d name=%-8.8s\n", i, g_peers[i].name); printf("DBG PEER_EXPIRE slot=%d name=%-8.8s\n", i, g_peers[i].name);
fflush(stdout); fflush(stdout);
#endif #endif
if (g_active_menu == MENU_LIFE && g_life_select >= 3 && g_ble_enabled) {
int slot = 0;
for (int j = 0; j < i; j++) if (g_peers[j].active) slot++;
if (g_life_select - 3 == slot) { g_life_select = 0; changed = 1; }
}
g_peers[i].active = 0; g_peers[i].active = 0;
} }
} }
@ -391,6 +604,15 @@ void app_main(void)
settings_save(); settings_dirty = 0; settings_save_tick = 0; settings_save(); settings_dirty = 0; settings_save_tick = 0;
} }
// ── Life delta timeout ────────────────────────────────────────────────
if (g_life_delta != 0) {
if (++g_life_delta_tick >= g_delta_timeout_ms / 10) {
g_life_delta = 0;
g_life_delta_tick = 0;
if (g_active_menu == MENU_LIFE) changed = 1;
}
}
// ── BLE adv update on game state change ─────────────────────────────── // ── BLE adv update on game state change ───────────────────────────────
if (changed && (g_active_menu==MENU_LIFE || g_active_menu==MENU_COUNTERS || if (changed && (g_active_menu==MENU_LIFE || g_active_menu==MENU_COUNTERS ||
g_active_menu==MENU_CMDR)) { g_active_menu==MENU_CMDR)) {
@ -405,10 +627,11 @@ void app_main(void)
} else { players_tick = 0; } } else { players_tick = 0; }
// ── LED ─────────────────────────────────────────────────────────────── // ── LED ───────────────────────────────────────────────────────────────
int bthresh = start_life_opts[g_start_life_index] / 4;
if (g_life == 0) { if (g_life == 0) {
led_update_for_count(0, 255); led_update_for_count(0, 255);
} else if (g_life < 10) { } else if (g_life < bthresh) {
float speed = 1.0f + (10.0f - g_life) / 9.0f * 2.0f; float speed = 1.0f + ((float)bthresh - g_life) / (float)(bthresh - 1) * 2.0f;
breath_phase += 2.0f * 3.14159265f * speed / 200.0f; breath_phase += 2.0f * 3.14159265f * speed / 200.0f;
uint8_t sc = (uint8_t)(127.5f + 127.5f * sinf(breath_phase)); uint8_t sc = (uint8_t)(127.5f + 127.5f * sinf(breath_phase));
led_update_for_count(g_life, sc); led_update_for_count(g_life, sc);
@ -420,8 +643,7 @@ void app_main(void)
if (changed) { if (changed) {
switch (g_active_menu) { switch (g_active_menu) {
case MENU_LIFE: case MENU_LIFE:
snprintf(nbuf, sizeof(nbuf), "%d", g_life); oled_draw_life();
oled_draw_centered(nbuf);
break; break;
case MENU_CMDR: case MENU_CMDR:
for (int i = 0; i < g_num_opponents; i++) { for (int i = 0; i < g_num_opponents; i++) {
@ -458,7 +680,7 @@ void app_main(void)
serial_print_state(); serial_print_state();
#endif #endif
prev_right = right; prev_left = left; prev_mid = mid; prev_fwd = fwd; prev_right = right; prev_left = left; prev_back = back;
vTaskDelay(pdMS_TO_TICKS(10)); vTaskDelay(pdMS_TO_TICKS(10));
} }
} }

@ -15,6 +15,7 @@ extern int g_active_counter;
extern int g_active_player; extern int g_active_player;
extern int g_active_setting; extern int g_active_setting;
extern int g_name_cursor; extern int g_name_cursor;
extern int g_life_select;
// Settings // Settings
extern int g_brightness_pct; extern int g_brightness_pct;
@ -25,9 +26,17 @@ extern int g_ble_enabled;
extern uint8_t g_led_max; extern uint8_t g_led_max;
extern int g_game_id_cursor; extern int g_game_id_cursor;
extern uint8_t g_game_id[2]; extern uint8_t g_game_id[2];
extern int g_menu_hold_ms;
extern int g_lr_hold_ms;
extern int g_display_flip;
// Peers // Peers
extern ble_peer_t g_peers[MAX_BLE_PEERS]; extern ble_peer_t g_peers[MAX_BLE_PEERS];
// Timing // Timing
extern uint32_t g_tick; extern uint32_t g_tick;
// Life delta overlay
extern int g_life_delta;
extern int g_life_delta_tick;
extern int g_delta_timeout_ms;