Moved menus to their own C files, updated L1 to 4040 footprint

main
noah metz 2026-07-07 10:44:52 -06:00
parent 5e45d07eb0
commit b976446030
26 changed files with 1801 additions and 1208 deletions

@ -7,7 +7,7 @@ PORT ?= /dev/ttyUSB0
# All firmware targets build with DEBUG=1 so serial commands and DBG lines
# are always available for testing and monitoring.
IDF := $(ESP_ENV) && idf.py -DDEBUG=1
IDF := $(ESP_ENV) && idf.py -b115200 -DDEBUG=1
.PHONY: all firmware sim test flash monitor flash-monitor clean firmware-clean sim-clean case case_live case_clean

@ -1,4 +1,7 @@
idf_component_register(SRCS "main.c" "draw.c" "ble.c" "game.c"
"menus.c"
"menu_life.c" "menu_cmdr.c" "menu_counters.c"
"menu_dice.c" "menu_settings.c"
INCLUDE_DIRS "."
PRIV_REQUIRES driver nvs_flash bt vfs esp_adc)

@ -1,5 +1,6 @@
#include "ble.h"
#include "state.h"
#include "esp_timer.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
@ -99,7 +100,7 @@ static void ble_update_peer(const ble_addr_t *addr, const ble_payload_t *p)
g_peers[slot].poison = p->poison;
g_peers[slot].eliminated = p->eliminated;
g_peers[slot].reset_cmd = p->reset_cmd;
g_peers[slot].last_seen = g_tick;
g_peers[slot].last_seen = esp_timer_get_time();
g_peers[slot].active = 1;
memcpy(g_peers[slot].name, p->name, PLAYER_NAME_LEN);
g_peers[slot].name[PLAYER_NAME_LEN] = '\0';

@ -20,7 +20,7 @@ typedef struct {
uint8_t poison;
uint8_t eliminated;
uint8_t reset_cmd;
uint32_t last_seen;
uint64_t last_seen;
int active;
} ble_peer_t;

@ -38,14 +38,12 @@
#define BLE_PEER_TIMEOUT 3000
// ── Menus ─────────────────────────────────────────────────────────────────────
#define NUM_MENUS 5
#define NUM_MENU_SLOTS 8
#define MENU_LIFE 0
#define MENU_CMDR 1
#define MENU_COUNTERS 2
#define MENU_DICE 3
#define MENU_SETTINGS 4
extern const int menu_slot[NUM_MENUS];
#define MENU_SETTINGS 7
// ── Dice ──────────────────────────────────────────────────────────────────────
#define DICE_CSV_LEN 128

@ -3,13 +3,12 @@
#include "game.h"
#include "font.h"
#include "state.h"
#include "menus.h"
#include "driver/ledc.h"
#include "driver/i2c.h"
#include "freertos/FreeRTOS.h"
#include <string.h>
#include <stdio.h>
const int menu_slot[NUM_MENUS] = {0, 1, 2, 3, 7};
const int die_sides[NUM_DICE_SIDES] = {4, 6, 8, 10, 12, 20, 100};
const char *setting_names[NUM_SETTINGS] = {
"LED BRIGHTNESS", "STARTING LIFE", "NUM OPPONENTS", "BLE",
@ -21,7 +20,8 @@ const char *setting_names[NUM_SETTINGS] = {
#define LEDC_TIMER_SEL LEDC_TIMER_0
#define LEDC_MODE_ LEDC_LOW_SPEED_MODE
#define LEDC_DUTY_RES_ LEDC_TIMER_8_BIT
#define LEDC_FREQ_HZ_ 5000
// RTC8M (8MHz) keeps LEDC alive during light sleep; max 8MHz/256 = 31.25kHz
#define LEDC_FREQ_HZ_ 977
// ── LED ───────────────────────────────────────────────────────────────────────
void led_init(void)
@ -31,7 +31,7 @@ void led_init(void)
.timer_num = LEDC_TIMER_SEL,
.duty_resolution = LEDC_DUTY_RES_,
.freq_hz = LEDC_FREQ_HZ_,
.clk_cfg = LEDC_AUTO_CLK,
.clk_cfg = LEDC_USE_RC_FAST_CLK,
};
ledc_timer_config(&timer);
ledc_channel_config_t ch[] = {
@ -142,10 +142,11 @@ void oled_draw_header(void)
{
uint8_t pages[HEADER_PAGES][OLED_WIDTH];
memset(pages, 0xFF, sizeof(pages));
for (int seg = 0; seg < NUM_MENUS; seg++) {
for (int seg = 0; seg < NUM_MENU_SLOTS; seg++) {
if (menus[seg] == NULL || menus[seg]->icon == NULL) continue;
uint8_t bg = (seg == g_active_menu) ? 0x00 : 0xFF;
int sx = menu_slot[seg] * SEG_W;
const uint8_t (*icon)[ICON_W] = (seg == MENU_CMDR && g_ble_enabled) ? icon_net : icons[seg];
int sx = seg * SEG_W;
const uint8_t (*icon)[ICON_W] = menus[seg]->icon;
for (int col = 0; col < ICON_W; col++) {
pages[0][sx+col] = bg ^ icon[0][col];
pages[1][sx+col] = bg ^ icon[1][col];
@ -155,7 +156,7 @@ void oled_draw_header(void)
// Slot 6 (cols 96-111): vertical battery icon, nub at top, fill from bottom
// or lightning bolt when charging
if (g_battery_pct >= 0) {
int show = (g_battery_pct >= BATT_LOW_PCT || (g_tick / 50) % 2 == 0);
int show = (g_battery_pct >= BATT_LOW_PCT || g_batt_blink);
if (show) {
if (!g_charging && !g_batt_full) {

@ -0,0 +1,29 @@
#pragma once
#include <stdint.h>
typedef enum {
EVT_BUTTON, // GPIO edge: gpio, level, ts_us
EVT_HOLD_EXPIRE, // per-button hold timer fired: btn_idx
EVT_COMBO_SLEEP, // all-4 held 5s
EVT_COMBO_SETTINGS, // fwd+back held 1s
EVT_BATT_TICK, // 500ms periodic
EVT_PEER_TICK, // 1s periodic
EVT_AUTOSAVE, // one-shot 30s: flush NVS
EVT_LIFE_DELTA_EXP, // one-shot: clear life delta overlay
EVT_RESET_CMD_EXP, // one-shot: end reset_cmd broadcast
EVT_IDLE_SLEEP, // one-shot: idle timeout
EVT_LED_BREATHE, // 10ms periodic: breathing animation
} evt_type_t;
typedef struct {
evt_type_t type;
int gpio; // EVT_BUTTON only
int level; // EVT_BUTTON only: 0=pressed 1=released
int64_t ts_us; // EVT_BUTTON only
int btn_idx; // EVT_HOLD_EXPIRE only: 0=fwd 1=left 2=right 3=back
} app_event_t;
#define BTN_IDX_FWD 0
#define BTN_IDX_LEFT 1
#define BTN_IDX_RIGHT 2
#define BTN_IDX_BACK 3

@ -64,26 +64,3 @@ static const uint8_t font5x8[][5] = {
{0x61,0x51,0x49,0x45,0x43}, // 'Z'
};
static const uint8_t icons[NUM_MENUS][2][ICON_W] = {
// Heart (life)
{ {0x00,0x70,0xF8,0xFC,0xFC,0xFC,0xF8,0xF0,0xF0,0xF8,0xFC,0xFC,0xFC,0xF8,0x70,0x00},
{0x00,0x00,0x00,0x01,0x03,0x07,0x0F,0x3F,0x3F,0x0F,0x07,0x03,0x01,0x00,0x00,0x00} },
// Shield (commander damage)
{ {0x00,0xFC,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFC,0x00,0x00},
{0x00,0x07,0x0F,0x1F,0x3F,0x3F,0x3F,0x7F,0x7F,0x3F,0x3F,0x1F,0x0F,0x07,0x00,0x00} },
// Plus (counters)
{ {0x00,0x00,0x00,0xC0,0xC0,0xC0,0xF8,0xF8,0xF8,0xF8,0xC0,0xC0,0xC0,0x00,0x00,0x00},
{0x00,0x00,0x00,0x03,0x03,0x03,0x1F,0x1F,0x1F,0x1F,0x03,0x03,0x03,0x00,0x00,0x00} },
// D6 showing 6-face: square outline + 6 dots in 2x3 arrangement
{ {0x00,0xFE,0x02,0x02,0x9A,0x9A,0x02,0x02,0x02,0x02,0x9A,0x9A,0x02,0x02,0xFE,0x00},
{0x00,0x7F,0x40,0x40,0x59,0x59,0x40,0x40,0x40,0x40,0x59,0x59,0x40,0x40,0x7F,0x00} },
// Cog (settings)
{ {0x00,0x80,0xDC,0xFC,0xFC,0xB8,0x9C,0xFE,0xFE,0x9C,0xB8,0xFC,0xFC,0xDC,0x80,0x00},
{0x00,0x01,0x3B,0x3F,0x3F,0x1D,0x39,0x7F,0x7F,0x39,0x1D,0x3F,0x3F,0x3B,0x01,0x00} },
};
// Signal bars shown on MENU_CMDR slot when BLE is enabled
static const uint8_t icon_net[2][ICON_W] = {
{0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x80,0x00,0xF8,0xF8,0x00,0xFE,0xFE,0x00,0x00},
{0x00,0x00,0x00,0x38,0x38,0x00,0x3F,0x3F,0x00,0x3F,0x3F,0x00,0x3F,0x3F,0x00,0x00},
};

File diff suppressed because it is too large Load Diff

@ -0,0 +1,95 @@
#include "menu_cmdr.h"
#include "menus.h"
#include "config.h"
#include "state.h"
#include "draw.h"
#include "game.h"
#include "ble.h"
#include "events.h"
#include <stdio.h>
// ── Offline vtable ────────────────────────────────────────────────────────────
static const uint8_t s_icon_shield[2][ICON_W] = {
// Shield (commander damage)
{0x00,0xFC,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFC,0x00,0x00},
{0x00,0x07,0x0F,0x1F,0x3F,0x3F,0x3F,0x7F,0x7F,0x3F,0x3F,0x1F,0x0F,0x07,0x00,0x00},
};
static const uint8_t s_icon_net[2][ICON_W] = {
// Signal bars (BLE commander)
{0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x80,0x00,0xF8,0xF8,0x00,0xFE,0xFE,0x00,0x00},
{0x00,0x00,0x00,0x38,0x38,0x00,0x3F,0x3F,0x00,0x3F,0x3F,0x00,0x3F,0x3F,0x00,0x00},
};
static void cmdr_offline_nav_short(int d)
{
g_active_opponent = (g_active_opponent + d + g_num_opponents) % g_num_opponents;
}
static void cmdr_offline_lr_delta(int d)
{
g_cmdr_damage[g_active_opponent] += d;
if (g_cmdr_damage[g_active_opponent] < 0) g_cmdr_damage[g_active_opponent] = 0;
check_elimination();
mark_dirty();
}
static void cmdr_offline_draw(void)
{
char opponent_labels[MAX_OPPONENTS][PLAYER_NAME_LEN + 1];
const char *opponent_label_ptrs[MAX_OPPONENTS];
for (int i = 0; i < g_num_opponents; i++) {
snprintf(opponent_labels[i], sizeof(opponent_labels[i]), "CMD %d", i + 1);
opponent_label_ptrs[i] = opponent_labels[i];
}
oled_draw_list(opponent_label_ptrs, g_cmdr_damage, g_num_opponents, g_active_opponent);
}
const menu_vtable_t menu_cmdr_offline_vtable = {
.nav_short = cmdr_offline_nav_short,
.lr_delta = cmdr_offline_lr_delta,
.draw = cmdr_offline_draw,
.on_tick = NULL,
.icon = s_icon_shield,
};
// ── BLE vtable ────────────────────────────────────────────────────────────────
static void cmdr_ble_nav_short(int d)
{
int total = MAX_BLE_PEERS + 1;
int next = (g_active_player + d + total) % total;
while (next != g_active_player && next != 0 && !g_peers[next - 1].active)
next = (next + d + total) % total;
g_active_player = next;
}
static void cmdr_ble_lr_delta(int d)
{
if (g_active_player == 0) {
g_life += d;
g_life_delta += d;
restart_life_delta_timer();
check_elimination();
} else {
int slot = g_active_player - 1;
g_cmdr_damage[slot] += d;
if (g_cmdr_damage[slot] < 0) g_cmdr_damage[slot] = 0;
check_elimination();
}
mark_dirty();
}
static void cmdr_ble_draw(void)
{
oled_draw_players();
}
const menu_vtable_t menu_cmdr_ble_vtable = {
.nav_short = cmdr_ble_nav_short,
.lr_delta = cmdr_ble_lr_delta,
.draw = cmdr_ble_draw,
.on_tick = NULL,
.icon = s_icon_net,
};

@ -0,0 +1,4 @@
#pragma once
#include "menus.h"
extern const menu_vtable_t menu_cmdr_offline_vtable;
extern const menu_vtable_t menu_cmdr_ble_vtable;

@ -0,0 +1,39 @@
#include "menu_counters.h"
#include "menus.h"
#include "config.h"
#include "state.h"
#include "draw.h"
#include "game.h"
#include "events.h"
static const uint8_t s_icon_plus[2][ICON_W] = {
// Plus (counters)
{0x00,0x00,0x00,0xC0,0xC0,0xC0,0xF8,0xF8,0xF8,0xF8,0xC0,0xC0,0xC0,0x00,0x00,0x00},
{0x00,0x00,0x00,0x03,0x03,0x03,0x1F,0x1F,0x1F,0x1F,0x03,0x03,0x03,0x00,0x00,0x00},
};
static void counters_nav_short(int d)
{
g_active_counter = (g_active_counter + d + NUM_COUNTERS) % NUM_COUNTERS;
}
static void counters_lr_delta(int d)
{
g_counters[g_active_counter] += d;
if (g_counters[g_active_counter] < 0) g_counters[g_active_counter] = 0;
check_elimination();
mark_dirty();
}
static void counters_draw(void)
{
oled_draw_list(counter_names, g_counters, NUM_COUNTERS, g_active_counter);
}
const menu_vtable_t menu_counters_vtable = {
.nav_short = counters_nav_short,
.lr_delta = counters_lr_delta,
.draw = counters_draw,
.on_tick = NULL,
.icon = s_icon_plus,
};

@ -0,0 +1,3 @@
#pragma once
#include "menus.h"
extern const menu_vtable_t menu_counters_vtable;

@ -0,0 +1,56 @@
#include "menu_dice.h"
#include "menus.h"
#include "config.h"
#include "state.h"
#include "draw.h"
#include "events.h"
#include "esp_random.h"
#include <stdio.h>
static const uint8_t s_icon_d6[2][ICON_W] = {
// D6 showing 6-face: square outline + 6 dots in 2x3 arrangement
{0x00,0xFE,0x02,0x02,0x9A,0x9A,0x02,0x02,0x02,0x02,0x9A,0x9A,0x02,0x02,0xFE,0x00},
{0x00,0x7F,0x40,0x40,0x59,0x59,0x40,0x40,0x40,0x40,0x59,0x59,0x40,0x40,0x7F,0x00},
};
static void dice_nav_short(int d)
{
g_dice_item = (g_dice_item + d + 3) % 3;
}
static void dice_lr_delta(int d)
{
if (g_dice_item == 0) {
g_dice_num += d;
if (g_dice_num < 1) g_dice_num = 1;
} else if (g_dice_item == 1) {
g_dice_sides = (g_dice_sides + d + NUM_DICE_SIDES) % NUM_DICE_SIDES;
} else {
int sides = die_sides[g_dice_sides];
g_dice_sum = 0;
int pos = 0;
g_dice_csv[0] = '\0';
for (int i = 0; i < g_dice_num; i++) {
int roll = (int)(esp_random() % (uint32_t)sides) + 1;
g_dice_sum += roll;
if (pos < DICE_CSV_LEN - 5) {
if (i > 0) g_dice_csv[pos++] = ',';
pos += snprintf(g_dice_csv + pos, DICE_CSV_LEN - pos, "%d", roll);
}
}
g_dice_rolled = 1;
}
}
static void dice_draw(void)
{
oled_draw_dice();
}
const menu_vtable_t menu_dice_vtable = {
.nav_short = dice_nav_short,
.lr_delta = dice_lr_delta,
.draw = dice_draw,
.on_tick = NULL,
.icon = s_icon_d6,
};

@ -0,0 +1,3 @@
#pragma once
#include "menus.h"
extern const menu_vtable_t menu_dice_vtable;

@ -0,0 +1,81 @@
#include "menu_life.h"
#include "menus.h"
#include "config.h"
#include "state.h"
#include "draw.h"
#include "game.h"
#include "ble.h"
#include "events.h"
static const uint8_t s_icon_life[2][ICON_W] = {
// Heart (life)
{0x00,0x70,0xF8,0xFC,0xFC,0xFC,0xF8,0xF0,0xF0,0xF8,0xFC,0xFC,0xFC,0xF8,0x70,0x00},
{0x00,0x00,0x00,0x01,0x03,0x07,0x0F,0x3F,0x3F,0x0F,0x07,0x03,0x01,0x00,0x00,0x00},
};
static void life_nav_short(int d)
{
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 + d + total) % total;
}
static void life_lr_delta(int d)
{
if (g_life_select == 0) {
g_life += d;
g_life_delta += d;
restart_life_delta_timer();
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();
}
}
mark_dirty();
}
static void life_draw(void)
{
oled_draw_life();
}
static void life_on_tick(const app_event_t *evt)
{
(void)evt;
if (g_life_delta != 0) {
g_life_delta = 0;
if (g_active_menu == MENU_LIFE) oled_draw_life();
}
}
const menu_vtable_t menu_life_vtable = {
.nav_short = life_nav_short,
.lr_delta = life_lr_delta,
.draw = life_draw,
.on_tick = life_on_tick,
.icon = s_icon_life,
};

@ -0,0 +1,3 @@
#pragma once
#include "menus.h"
extern const menu_vtable_t menu_life_vtable;

@ -0,0 +1,164 @@
#include "menu_settings.h"
#include "menu_cmdr.h"
#include "menus.h"
#include "config.h"
#include "state.h"
#include "draw.h"
#include "game.h"
#include "ble.h"
#include "events.h"
#include <string.h>
static const uint8_t s_icon_cog[2][ICON_W] = {
// Cog (settings)
{0x00,0x80,0xDC,0xFC,0xFC,0xB8,0x9C,0xFE,0xFE,0x9C,0xB8,0xFC,0xFC,0xDC,0x80,0x00},
{0x00,0x01,0x3B,0x3F,0x3F,0x1D,0x39,0x7F,0x7F,0x39,0x1D,0x3F,0x3F,0x3B,0x01,0x00},
};
static void settings_nav_short(int d)
{
if (g_active_setting == SET_PLAYER_NAME) {
if (d > 0) {
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_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 (d > 0) {
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 (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 {
if (d > 0) {
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_name_cursor = 0;
g_game_id_cursor = 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;
}
}
}
static void settings_lr_delta(int d)
{
mark_dirty();
switch (g_active_setting) {
case SET_BRIGHTNESS:
g_brightness_pct += d;
if (g_brightness_pct < BRIGHTNESS_MIN) g_brightness_pct = BRIGHTNESS_MIN;
if (g_brightness_pct > BRIGHTNESS_MAX) g_brightness_pct = BRIGHTNESS_MAX;
g_led_max = (uint8_t)(255 * g_brightness_pct / 100);
break;
case SET_START_LIFE:
g_start_life_index += d;
if (g_start_life_index < 0) g_start_life_index = 0;
if (g_start_life_index >= NUM_LIFE_OPTS) g_start_life_index = NUM_LIFE_OPTS - 1;
break;
case SET_NUM_OPP:
g_num_opponents += d;
if (g_num_opponents < 1) g_num_opponents = 1;
if (g_num_opponents > MAX_OPPONENTS) g_num_opponents = MAX_OPPONENTS;
if (g_active_opponent >= g_num_opponents) g_active_opponent = 0;
break;
case SET_PLAYER_NAME: {
char curr = g_player_name[g_name_cursor];
const char *p = strchr(NAME_CHARS, curr);
int idx = p ? (int)(p - NAME_CHARS) : 0;
idx = (idx + d + NUM_NAME_CHARS) % NUM_NAME_CHARS;
g_player_name[g_name_cursor] = NAME_CHARS[idx];
break;
}
case SET_BLE:
g_ble_enabled += d;
if (g_ble_enabled < 0) g_ble_enabled = 0;
if (g_ble_enabled > 1) g_ble_enabled = 1;
menus[MENU_CMDR] = g_ble_enabled ? &menu_cmdr_ble_vtable : &menu_cmdr_offline_vtable;
ble_adv_update();
oled_draw_header();
break;
case SET_GAME_ID: {
int byte = g_game_id_cursor / 2;
int nibble = g_game_id_cursor % 2;
int val = nibble == 0 ? (g_game_id[byte] >> 4) & 0xF : g_game_id[byte] & 0xF;
val = (val + d + NUM_HEX_CHARS) % NUM_HEX_CHARS;
if (nibble == 0)
g_game_id[byte] = (g_game_id[byte] & 0x0F) | ((uint8_t)val << 4);
else
g_game_id[byte] = (g_game_id[byte] & 0xF0) | (uint8_t)val;
ble_adv_update();
break;
}
case SET_RESET:
game_reset();
ble_adv_update();
break;
case SET_RESET_ALL:
game_reset();
g_reset_cmd_ticks = 1;
start_reset_cmd_timer();
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;
case SET_AUTO_SLEEP:
g_sleep_timeout_min += d;
if (g_sleep_timeout_min < 0) g_sleep_timeout_min = 0;
if (g_sleep_timeout_min > SLEEP_TIMEOUT_MAX) g_sleep_timeout_min = SLEEP_TIMEOUT_MAX;
restart_idle_timer();
break;
}
}
static void settings_draw(void)
{
oled_draw_settings();
}
const menu_vtable_t menu_settings_vtable = {
.nav_short = settings_nav_short,
.lr_delta = settings_lr_delta,
.draw = settings_draw,
.on_tick = NULL,
.icon = s_icon_cog,
};

@ -0,0 +1,3 @@
#pragma once
#include "menus.h"
extern const menu_vtable_t menu_settings_vtable;

@ -0,0 +1,50 @@
#include "menus.h"
#include "menu_life.h"
#include "menu_cmdr.h"
#include "menu_counters.h"
#include "menu_dice.h"
#include "menu_settings.h"
#include "state.h"
#include "ble.h"
// Array index = header slot position (0NUM_MENU_SLOTS-1).
// Initialized with offline CMDR vtable; menus_init() fixes up slot 1 based on g_ble_enabled.
const menu_vtable_t *menus[NUM_MENU_SLOTS] = {
[MENU_LIFE] = &menu_life_vtable, // slot 0 — life counter
[MENU_CMDR] = &menu_cmdr_offline_vtable, // slot 1 — commander damage (fixed by menus_init)
[MENU_COUNTERS] = &menu_counters_vtable, // slot 2 — counters
[MENU_DICE] = &menu_dice_vtable, // slot 3 — dice
[4] = NULL, // slot 4 — empty
[5] = NULL, // slot 5 — battery pct (special in oled_draw_header)
[6] = NULL, // slot 6 — battery icon (special in oled_draw_header)
[MENU_SETTINGS] = &menu_settings_vtable, // slot 7 — FWD+BACK combo to enter/exit
};
void menus_init(void)
{
menus[MENU_CMDR] = g_ble_enabled ? &menu_cmdr_ble_vtable : &menu_cmdr_offline_vtable;
}
void menus_nav_short(int d)
{
const menu_vtable_t *m = menus[g_active_menu];
if (m && m->nav_short) m->nav_short(d);
menus_draw();
}
void menus_lr_delta(int d)
{
const menu_vtable_t *m = menus[g_active_menu];
if (m && m->lr_delta) m->lr_delta(d);
menus_draw();
if (g_active_menu == MENU_LIFE || g_active_menu == MENU_COUNTERS ||
g_active_menu == MENU_CMDR)
ble_adv_update();
led_sync(true);
}
void menus_draw(void)
{
const menu_vtable_t *m = menus[g_active_menu];
if (m && m->draw) m->draw();
}

@ -0,0 +1,30 @@
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include "config.h"
#include "events.h"
typedef struct {
void (*nav_short)(int d);
void (*lr_delta)(int d);
void (*draw)(void);
void (*on_tick)(const app_event_t *evt); // NULL if unused
const uint8_t (*icon)[ICON_W]; // 2-page icon [2][ICON_W]
} menu_vtable_t;
// Array index = header slot position (0NUM_MENU_SLOTS-1).
// NULL = empty/non-interactive slot.
// Last slot (NUM_MENU_SLOTS-1) = FWD+BACK combo to enter/exit.
extern const menu_vtable_t *menus[NUM_MENU_SLOTS];
void menus_init(void);
void menus_nav_short(int d);
void menus_lr_delta(int d);
void menus_draw(void);
// Helpers from main.c exposed for menu_*.c files
void mark_dirty(void);
void restart_life_delta_timer(void);
void restart_idle_timer(void);
void start_reset_cmd_timer(void);
void led_sync(bool changed);

@ -1,4 +1,5 @@
#pragma once
#include <stdbool.h>
#include "config.h"
#include "ble.h"
@ -39,12 +40,11 @@ extern int g_charging; // 1 when charger is connected (BATT_CHARGE
extern int g_batt_full; // 1 when battery is full (BATT_FULL_GPIO low)
extern int g_sleep_timeout_min; // 0 = disabled
// Timing
extern uint32_t g_tick;
// Battery blink toggle (driven by batt timer)
extern bool g_batt_blink;
// Life delta overlay
extern int g_life_delta;
extern int g_life_delta_tick;
extern int g_delta_timeout_ms;
// Dice

File diff suppressed because it is too large Load Diff

@ -1,7 +1,7 @@
{
"board": {
"active_layer": 2,
"active_layer_preset": "Back Layers",
"active_layer_preset": "All Layers",
"auto_track_width": true,
"hidden_netclasses": [],
"hidden_nets": [],
@ -53,7 +53,7 @@
"board_outline_area",
"ly_points"
],
"visible_layers": "00000000_00000000_00000002_2200888c",
"visible_layers": "ffffffff_ffffffff_ffffffff_ffffffff",
"zone_display_mode": 0
},
"git": {

@ -19066,7 +19066,7 @@
(justify right)
)
)
(property "Footprint" "Inductor_SMD:L_Taiyo-Yuden_MD-3030"
(property "Footprint" "Inductor_SMD:L_Taiyo-Yuden_MD-4040"
(at 68.58 -21.59 0)
(hide yes)
(show_name no)

@ -1,3 +1,6 @@
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_CONTROLLER_ENABLED=y
CONFIG_PM_ENABLE=y
CONFIG_FREERTOS_USE_TICKLESS_IDLE=y
CONFIG_FREERTOS_IDLE_TIME_BEFORE_SLEEP=3