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/output/*.stl
case/output/
build/
pcb/.history
pcb/~*
compile_commands.json

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

@ -13,6 +13,7 @@ const int menu_slot[NUM_MENUS] = {0, 1, 2, 7};
const char *setting_names[NUM_SETTINGS] = {
"LED BRIGHTNESS", "STARTING LIFE", "NUM OPPONENTS", "BLE",
"RESET", "RESET ALL", "GAME ID", "PLAYER NAME",
"MENU HOLD MS", "LR HOLD MS", "FLIP DISPLAY", "DELTA TIMEOUT",
};
#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)
{
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;
if (c <= 40) {
int cc = c < 10 ? 10 : c;
r = (uint8_t)((uint32_t)(40-cc)*scale*g_led_max/30/255);
g = (uint8_t)((uint32_t)(cc-10)*scale*g_led_max/30/255);
if (c <= sl) {
int cc = c < low ? low : c;
r = (uint8_t)((uint32_t)(sl - cc) * scale * g_led_max / range / 255);
g = (uint8_t)((uint32_t)(cc - low) * scale * g_led_max / range / 255);
b = 0;
} else {
r = 0;
g = (uint8_t)((uint32_t)(80-c)*g_led_max/40);
b = (uint8_t)((uint32_t)(c-40)*g_led_max/40);
g = (uint8_t)((uint32_t)(maxl - c) * g_led_max / sl);
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_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);
}
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 ──────────────────────────────────────────────────────────────────────
static void oled_write(const uint8_t *buf, size_t len)
{
@ -70,17 +82,52 @@ void oled_init(void)
{
static const uint8_t seq[] = {
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]);
}
void oled_set_flip(int flip)
{
oled_cmd(flip ? 0xA1 : 0xA0);
oled_cmd(flip ? 0xC8 : 0xC0);
}
void oled_clear(void)
{
uint8_t row[OLED_WIDTH]; memset(row, 0, sizeof(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)
{
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)
{
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++) {
uint8_t bg = (seg == g_active_menu) ? 0x00 : 0xFF;
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;
}
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 ────────────────────────────────────────────────────────
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;
snprintf(rows[SET_RESET].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);
}
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_update_for_count(int count, uint8_t scale);
void led_off(void);
void oled_init(void);
void oled_set_flip(int flip);
void oled_clear(void);
void oled_set_contrast(uint8_t val);
void oled_draw_sleep(void);
void oled_draw_header(void);
void oled_draw_centered(const char *str);
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_players(void);
void oled_draw_settings(void);
void oled_draw_life(void);

@ -9,7 +9,7 @@
const int start_life_opts[] = {20, 30, 40};
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)
{
@ -18,10 +18,14 @@ void settings_reset_defaults(void)
g_num_opponents = 3;
strncpy(g_player_name, "PLAYER 1", PLAYER_NAME_LEN);
g_player_name[PLAYER_NAME_LEN] = '\0';
g_ble_enabled = 0;
g_led_max = 26;
g_game_id[0] = BLE_GAME_ID_0;
g_game_id[1] = BLE_GAME_ID_1;
g_ble_enabled = 0;
g_led_max = 26;
g_game_id[0] = BLE_GAME_ID_0;
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)
@ -34,10 +38,10 @@ void game_reset(void)
void check_elimination(void)
{
if (g_eliminated) return;
if (g_life <= 0 || g_counters[0] >= 10) { g_eliminated = 1; return; }
for (int i = 0; i < g_num_opponents; i++)
if (g_cmdr_damage[i] >= 21) { g_eliminated = 1; return; }
g_eliminated = 0;
}
void settings_load(void)
@ -55,9 +59,12 @@ void settings_load(void)
}
size_t len = sizeof(g_player_name);
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, "life", &val) == ESP_OK) g_life = (int)val;
if (nvs_get_i32(nvs, "elim", &val) == ESP_OK) g_eliminated = (int)val;
if (nvs_get_i32(nvs, "life", &val) == ESP_OK) g_life = (int)val;
char key[8];
for (int i = 0; i < MAX_OPPONENTS; 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, "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, "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_i32(nvs, "player_id", (int32_t)g_player_id);
nvs_set_i32(nvs, "life", (int32_t)g_life);
nvs_set_i32(nvs, "elim", (int32_t)g_eliminated);
char key[8];
for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(key, sizeof(key), "cmdr%d", i);

@ -17,6 +17,8 @@
#include <math.h>
// ── Global state ──────────────────────────────────────────────────────────────
int g_sleep_mode = 0;
// Game
int g_life;
int g_cmdr_damage[MAX_OPPONENTS];
@ -30,6 +32,7 @@ int g_active_counter;
int g_active_player;
int g_active_setting;
int g_name_cursor;
int g_life_select;
// Settings
int g_brightness_pct = 10;
@ -40,6 +43,9 @@ int g_ble_enabled = 0;
uint8_t g_led_max = 26;
int g_game_id_cursor = 0;
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
ble_peer_t g_peers[MAX_BLE_PEERS];
@ -47,6 +53,11 @@ ble_peer_t g_peers[MAX_BLE_PEERS];
// Timing
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 ───────────────────────────────────────────────────────
static void serial_print_state(void)
{
@ -140,13 +151,16 @@ void app_main(void)
}
g_life = start_life_opts[g_start_life_index];
settings_load();
check_elimination();
g_led_max = (uint8_t)(255 * g_brightness_pct / 100);
oled_init();
oled_set_flip(g_display_flip);
oled_clear();
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,
.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);
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);
#ifdef DEBUG
serial_print_state();
#endif
int prev_right=1, prev_left=1, prev_mid=1;
int hold_right=0, hold_left=0, hold_mid=0, mid_fired_hold=0;
int prev_fwd=1, prev_right=1, prev_left=1, prev_back=1;
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
// hold fires when rapid taps have sub-10ms releases that the sampler misses.
int release_r=2, release_l=2;
int hold_armed_r=1, hold_armed_l=1;
int players_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)
float breath_phase=0.0f;
char nbuf[12];
char opponent_labels[MAX_OPPONENTS][PLAYER_NAME_LEN+1];
const char *opponent_label_ptrs[MAX_OPPONENTS];
while (1) {
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 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;
// ── Middle button ────────────────────────────────────────────────────
if (mid == 0) {
hold_mid++;
if (!mid_fired_hold && hold_mid == MID_HOLD_SETTINGS) {
// ── Combo: all 4 buttons → sleep toggle ──────────────────────────────
int all4 = (fwd==0 && back==0 && left==0 && right==0);
if (all4) {
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) {
g_active_menu = 0;
} else {
@ -201,89 +263,216 @@ void app_main(void)
g_name_cursor = 0;
g_game_id_cursor = 0;
}
mid_fired_hold = 1;
combo_fired_hold = 1;
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
}
} else {
if (prev_mid == 0 && !mid_fired_hold) {
if (hold_mid >= MID_HOLD_MENU) {
if (g_active_menu == MENU_SETTINGS) {
g_active_menu = 0;
} else {
combo_tick = 0;
combo_fired_hold = 0;
// ── FORWARD: long press = next menu (+ repeat), short press = sub-item fwd ──
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);
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
fwd_menu_fired = 1;
}
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
} else if (g_active_menu == MENU_SETTINGS) {
if (g_active_setting == SET_PLAYER_NAME) {
g_name_cursor++;
if (g_name_cursor >= PLAYER_NAME_LEN) {
g_name_cursor = 0;
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_game_id_cursor = 0;
}
} else if (g_active_setting == SET_GAME_ID) {
g_game_id_cursor++;
if (g_game_id_cursor >= GAME_ID_DIGITS) {
g_game_id_cursor = 0;
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
}
} 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) {
g_name_cursor++;
if (g_name_cursor >= PLAYER_NAME_LEN) {
g_name_cursor = 0;
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_game_id_cursor = 0;
}
} else if (g_active_setting == SET_GAME_ID) {
g_game_id_cursor++;
if (g_game_id_cursor >= GAME_ID_DIGITS) {
g_game_id_cursor = 0;
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
}
} else {
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_name_cursor = 0;
g_game_id_cursor = 0;
}
changed = 1;
} else if (g_active_menu == MENU_CMDR) {
if (g_ble_enabled) {
int next = (g_active_player + 1) % (MAX_BLE_PEERS + 1);
while (next != g_active_player && next != 0 && !g_peers[next - 1].active)
next = (next + 1) % (MAX_BLE_PEERS + 1);
g_active_player = next;
} else {
g_active_opponent = (g_active_opponent + 1) % g_num_opponents;
}
changed = 1;
} else if (g_active_menu == MENU_COUNTERS) {
g_active_counter = (g_active_counter + 1) % 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++;
g_life_select = (g_life_select + 1) % (3 + nc);
changed = 1;
}
} else {
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_name_cursor = 0;
g_game_id_cursor = 0;
}
changed = 1;
} else if (g_active_menu == MENU_CMDR) {
if (g_ble_enabled) {
int next = (g_active_player + 1) % (MAX_BLE_PEERS + 1);
while (next != g_active_player && next != 0 && !g_peers[next - 1].active)
next = (next + 1) % (MAX_BLE_PEERS + 1);
g_active_player = next;
} else {
g_active_opponent = (g_active_opponent + 1) % g_num_opponents;
}
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;
}
}
changed = 1;
} else if (g_active_menu == MENU_COUNTERS) {
g_active_counter = (g_active_counter + 1) % NUM_COUNTERS;
changed = 1;
}
hold_back = 0;
back_in_combo = 0;
back_menu_fired = 0;
}
hold_mid = 0; mid_fired_hold = 0;
}
// ── Left / Right ─────────────────────────────────────────────────────
// ── delta[0]: value change (right / left) ────────────────────────────
int delta_r=0, delta_l=0;
if (right==0) {
if (prev_right==1) {
delta_r=1; hold_right=0;
hold_armed_r = (release_r >= 2);
release_r = 0;
} else if (hold_armed_r && ++hold_right>=HOLD_DELAY && (hold_right-HOLD_DELAY)%HOLD_REPEAT==0) {
delta_r=1;
}
} else { hold_right=0; if (release_r < 2) release_r++; }
if (left==0) {
if (prev_left==1) {
delta_l=1; hold_left=0;
hold_armed_l = (release_l >= 2);
release_l = 0;
} else if (hold_armed_l && ++hold_left>=HOLD_DELAY && (hold_left-HOLD_DELAY)%HOLD_REPEAT==0) {
delta_l=1;
}
} else { hold_left=0; if (release_l < 2) release_l++; }
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 (prev_right==1) {
delta_r=1; hold_right=0;
hold_armed_r = (release_r >= 2); release_r=0;
} else if (hold_armed_r && ++hold_right>=lr_ticks
&& (hold_right-lr_ticks)%HOLD_REPEAT==0) {
delta_r=1;
}
} else { hold_right=0; if (release_r<2) release_r++; }
if (left==0) {
if (prev_left==1) {
delta_l=1; hold_left=0;
hold_armed_l = (release_l >= 2); release_l=0;
} else if (hold_armed_l && ++hold_left>=lr_ticks
&& (hold_left-lr_ticks)%HOLD_REPEAT==0) {
delta_l=1;
}
} else { hold_left=0; if (release_l<2) release_l++; }
}
if (delta_r || delta_l) {
int d = delta_r - delta_l;
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) {
if (g_ble_enabled) {
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 {
int slot = g_active_player - 1;
g_cmdr_damage[slot] += d;
@ -356,6 +545,25 @@ void app_main(void)
g_reset_cmd_ticks = 1500;
ble_adv_update();
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);
fflush(stdout);
#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;
}
}
@ -391,6 +604,15 @@ void app_main(void)
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 ───────────────────────────────
if (changed && (g_active_menu==MENU_LIFE || g_active_menu==MENU_COUNTERS ||
g_active_menu==MENU_CMDR)) {
@ -405,10 +627,11 @@ void app_main(void)
} else { players_tick = 0; }
// ── LED ───────────────────────────────────────────────────────────────
int bthresh = start_life_opts[g_start_life_index] / 4;
if (g_life == 0) {
led_update_for_count(0, 255);
} else if (g_life < 10) {
float speed = 1.0f + (10.0f - g_life) / 9.0f * 2.0f;
} else if (g_life < bthresh) {
float speed = 1.0f + ((float)bthresh - g_life) / (float)(bthresh - 1) * 2.0f;
breath_phase += 2.0f * 3.14159265f * speed / 200.0f;
uint8_t sc = (uint8_t)(127.5f + 127.5f * sinf(breath_phase));
led_update_for_count(g_life, sc);
@ -420,8 +643,7 @@ void app_main(void)
if (changed) {
switch (g_active_menu) {
case MENU_LIFE:
snprintf(nbuf, sizeof(nbuf), "%d", g_life);
oled_draw_centered(nbuf);
oled_draw_life();
break;
case MENU_CMDR:
for (int i = 0; i < g_num_opponents; i++) {
@ -458,7 +680,7 @@ void app_main(void)
serial_print_state();
#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));
}
}

@ -15,6 +15,7 @@ extern int g_active_counter;
extern int g_active_player;
extern int g_active_setting;
extern int g_name_cursor;
extern int g_life_select;
// Settings
extern int g_brightness_pct;
@ -25,9 +26,17 @@ extern int g_ble_enabled;
extern uint8_t g_led_max;
extern int g_game_id_cursor;
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
extern ble_peer_t g_peers[MAX_BLE_PEERS];
// Timing
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;