diff --git a/.gitignore b/.gitignore index 36f60a5..bce52b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ case/venv/ -case/output/*.stl +case/output/ +build/ +pcb/.history +pcb/~* +compile_commands.json diff --git a/main/config.h b/main/config.h index dd0ea9c..ae0f508 100644 --- a/main/config.h +++ b/main/config.h @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/main/draw.c b/main/draw.c index f85bbd0..5ccc482 100644 --- a/main/draw.c +++ b/main/draw.c @@ -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]); +} diff --git a/main/draw.h b/main/draw.h index 5a4f359..60a3ab0 100644 --- a/main/draw.h +++ b/main/draw.h @@ -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); diff --git a/main/game.c b/main/game.c index f8e7ead..631ae3a 100644 --- a/main/game.c +++ b/main/game.c @@ -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); diff --git a/main/main.c b/main/main.c index 0136d03..ed3f271 100644 --- a/main/main.c +++ b/main/main.c @@ -17,6 +17,8 @@ #include // ── 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<= 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)); } } diff --git a/main/state.h b/main/state.h index 4732762..257e7c2 100644 --- a/main/state.h +++ b/main/state.h @@ -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;