commeownder/main/main.c

813 lines
36 KiB
C

#include "config.h"
#include "state.h"
#include "game.h"
#include "draw.h"
#include "ble.h"
#include "driver/gpio.h"
#include "driver/i2c.h"
#include "driver/uart.h"
#include "esp_vfs_dev.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_err.h"
#include "esp_random.h"
#include "esp_sleep.h"
#include "esp_adc/adc_oneshot.h"
#include "esp_adc/adc_cali.h"
#include "esp_adc/adc_cali_scheme.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
// ── Global state ──────────────────────────────────────────────────────────────
int g_sleep_mode = 0;
int g_battery_pct = -1;
int g_sleep_timeout_min = SLEEP_TIMEOUT_DEF;
static adc_oneshot_unit_handle_t s_adc;
static adc_cali_handle_t s_adc_cali;
static bool s_adc_cali_ok;
// Game
int g_life;
int g_cmdr_damage[MAX_OPPONENTS];
int g_counters[NUM_COUNTERS];
int g_eliminated;
// UI
int g_active_menu;
int g_active_opponent;
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;
int g_start_life_index = 2; // 40
int g_num_opponents = 3;
char g_player_name[PLAYER_NAME_LEN + 1] = "PLAYER 1";
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];
// 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;
// Dice
int g_dice_num = 1;
int g_dice_sides = 5; // index into die_sides: d20
int g_dice_item = 0;
char g_dice_csv[DICE_CSV_LEN];
int g_dice_rolled = 0;
int g_dice_sum = 0;
// ── Serial command task ───────────────────────────────────────────────────────
static void serial_print_state(void)
{
printf("DBG STATE life=%d poison=%u cmdr=[%d,%d,%d,%d] counters=[%d,%d,%d] menu=%d ble=%d eliminated=%d\n",
g_life, (unsigned)g_counters[0],
g_cmdr_damage[0], g_cmdr_damage[1], g_cmdr_damage[2], g_cmdr_damage[3],
g_counters[0], g_counters[1], g_counters[2],
g_active_menu, g_ble_enabled, g_eliminated);
fflush(stdout);
}
static void serial_cmd_task(void *arg)
{
(void)arg;
char buf[64];
int pos = 0;
for (;;) {
int c = fgetc(stdin);
if (c < 0) { vTaskDelay(pdMS_TO_TICKS(10)); continue; }
if (c == '\n' || c == '\r') {
if (pos > 0) {
buf[pos] = '\0';
pos = 0;
int val = 0;
if (strncmp(buf, "SET ", 4) == 0) {
const char *kv = buf + 4;
if (sscanf(kv, "life=%d", &val) == 1) {
g_life = val; check_elimination();
}
if (sscanf(kv, "ble=%d", &val) == 1) {
g_ble_enabled = val ? 1 : 0;
}
{
char fmt[14];
for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(fmt, sizeof(fmt), "cmdr%d=%%d", i);
if (sscanf(kv, fmt, &val) == 1) {
g_cmdr_damage[i] = val < 0 ? 0 : val; check_elimination(); break;
}
}
for (int i = 0; i < NUM_COUNTERS; i++) {
snprintf(fmt, sizeof(fmt), "counter%d=%%d", i);
if (sscanf(kv, fmt, &val) == 1) {
g_counters[i] = val < 0 ? 0 : val; check_elimination(); break;
}
}
}
ble_adv_update();
serial_print_state();
} else if (strcmp(buf, "RESET") == 0) {
game_reset();
ble_adv_update();
serial_print_state();
} else if (strcmp(buf, "CLEARNVS") == 0) {
nvs_handle_t nvs;
if (nvs_open(NVS_NS, NVS_READWRITE, &nvs) == ESP_OK) {
nvs_erase_all(nvs);
nvs_commit(nvs);
nvs_close(nvs);
}
settings_reset_defaults();
game_reset();
ble_adv_update();
serial_print_state();
} else if (strcmp(buf, "STATE") == 0) {
serial_print_state();
}
}
} else if (pos < (int)sizeof(buf) - 1) {
buf[pos++] = (char)c;
}
}
}
// ── app_main ──────────────────────────────────────────────────────────────────
void app_main(void)
{
led_init();
i2c_config_t i2c_cfg = {
.mode=I2C_MODE_MASTER, .sda_io_num=I2C_SDA_GPIO, .scl_io_num=I2C_SCL_GPIO,
.sda_pullup_en=GPIO_PULLUP_ENABLE, .scl_pullup_en=GPIO_PULLUP_ENABLE,
.master.clk_speed=I2C_FREQ_HZ,
};
ESP_ERROR_CHECK(i2c_param_config(I2C_PORT, &i2c_cfg));
ESP_ERROR_CHECK(i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0));
esp_err_t nvs_err = nvs_flash_init();
if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); nvs_flash_init();
}
g_life = start_life_opts[g_start_life_index];
settings_load();
check_elimination();
g_led_max = (uint8_t)(255 * g_brightness_pct / 100);
// ADC for battery voltage (GPIO34, ADC1 CH6, 100k/100k divider)
adc_oneshot_unit_init_cfg_t adc_cfg = {.unit_id = ADC_UNIT_1};
adc_oneshot_new_unit(&adc_cfg, &s_adc);
adc_oneshot_chan_cfg_t ch_cfg = {
.atten = ADC_ATTEN_DB_11,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
adc_oneshot_config_channel(s_adc, ADC_CHANNEL_6, &ch_cfg);
adc_cali_line_fitting_config_t cali_cfg = {
.unit_id = ADC_UNIT_1,
.atten = ADC_ATTEN_DB_11,
.bitwidth = ADC_BITWIDTH_DEFAULT,
};
s_adc_cali_ok = (adc_cali_create_scheme_line_fitting(&cali_cfg, &s_adc_cali) == ESP_OK);
oled_init();
oled_set_flip(g_display_flip);
oled_clear();
gpio_config_t gpio_cfg = {
.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,
};
gpio_config(&gpio_cfg);
ble_init();
uart_driver_install(UART_NUM_0, 256, 0, 0, NULL, 0);
esp_vfs_dev_uart_use_driver(0);
setvbuf(stdin, NULL, _IONBF, 0);
xTaskCreate(serial_cmd_task, "serial_cmd", 4096, NULL, 3, NULL);
oled_draw_header();
oled_draw_life();
led_update_for_count(g_life, 255);
#ifdef DEBUG
serial_print_state();
#endif
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;
uint32_t idle_ticks=0, batt_tick=0;
gpio_wakeup_enable(BTN_FORWARD_GPIO, GPIO_INTR_LOW_LEVEL);
gpio_wakeup_enable(BTN_LEFT_GPIO, GPIO_INTR_LOW_LEVEL);
gpio_wakeup_enable(BTN_RIGHT_GPIO, GPIO_INTR_LOW_LEVEL);
gpio_wakeup_enable(BTN_BACK_GPIO, GPIO_INTR_LOW_LEVEL);
esp_sleep_enable_gpio_wakeup();
#define MARK_DIRTY() do { settings_dirty = 1; settings_save_tick = 0; } while (0)
float breath_phase=0.0f;
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 back = gpio_get_level(BTN_BACK_GPIO);
int changed = 0;
// ── 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 {
g_active_menu = MENU_SETTINGS;
g_active_setting = 0;
g_name_cursor = 0;
g_game_id_cursor = 0;
}
combo_fired_hold = 1;
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
}
} 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;
}
}
} 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_DICE) {
g_dice_item = (g_dice_item + 1) % 3;
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_DICE) {
g_dice_item = (g_dice_item - 1 + 3) % 3;
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_back = 0;
back_in_combo = 0;
back_menu_fired = 0;
}
}
// ── delta[0]: value change (right / left) ────────────────────────────
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 (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) {
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;
g_life_delta += d;
g_life_delta_tick = 0;
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();
}
} else {
g_cmdr_damage[g_active_opponent] += d;
if (g_cmdr_damage[g_active_opponent] < 0) g_cmdr_damage[g_active_opponent] = 0;
check_elimination();
}
changed = 1; MARK_DIRTY();
} else if (g_active_menu == MENU_COUNTERS) {
g_counters[g_active_counter] += d;
if (g_counters[g_active_counter] < 0) g_counters[g_active_counter] = 0;
changed = 1; MARK_DIRTY(); check_elimination();
} else if (g_active_menu == MENU_DICE) {
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;
}
changed = 1;
} else if (g_active_menu == MENU_SETTINGS) {
changed = 1; 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 *pos = strchr(NAME_CHARS, curr);
int idx = pos ? (int)(pos - 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;
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 = 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;
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;
idle_ticks = 0;
break;
}
}
}
// ── Remote reset command ──────────────────────────────────────────────
if (g_reset_requested) {
g_reset_requested = 0;
game_reset();
MARK_DIRTY();
changed = 1;
ble_adv_update();
}
// ── reset_cmd broadcast countdown ────────────────────────────────────
if (g_reset_cmd_ticks > 0 && --g_reset_cmd_ticks == 0) ble_adv_update();
// ── Peer expiry + player ID resolution ───────────────────────────────
if (g_tick % 100 == 0) {
for (int i = 0; i < MAX_BLE_PEERS; i++) {
if (g_peers[i].active && g_tick - g_peers[i].last_seen > BLE_PEER_TIMEOUT) {
#ifdef DEBUG
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;
}
}
}
// ── Autosave settings ─────────────────────────────────────────────────
if (settings_dirty && ++settings_save_tick >= SAVE_DELAY) {
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)) {
ble_adv_update();
}
// ── Players periodic refresh ──────────────────────────────────────────
if (g_active_menu == MENU_CMDR && g_ble_enabled) {
if (++players_tick >= 100) {
players_tick = 0; oled_draw_players();
}
} 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 < 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);
} else if (changed) {
led_update_for_count(g_life, 255);
}
// ── Battery voltage sampling ──────────────────────────────────────────
if (g_battery_pct >= 0 && g_battery_pct < BATT_LOW_PCT && g_tick % 50 == 0)
oled_draw_header();
if (++batt_tick >= BATT_SAMPLE_TICKS) {
batt_tick = 0;
int raw, voltage_mv;
adc_oneshot_read(s_adc, ADC_CHANNEL_6, &raw);
if (s_adc_cali_ok)
adc_cali_raw_to_voltage(s_adc_cali, raw, &voltage_mv);
else
voltage_mv = raw * 3100 / 4095;
int cell_mv = voltage_mv * 2; // 100k/100k voltage divider
// LiPo discharge curve: piecewise linear interpolation.
// Voltage stays flat ~3.7-4.2V through most of charge; linear
// mapping reads too high. Table derived from typical LiPo OCV curve.
static const int lipo_mv[] = {3000,3300,3400,3500,3600,3700,3800,3900,4000,4100,4200};
static const int lipo_pct[] = { 0, 3, 8, 18, 34, 50, 62, 72, 81, 92, 100};
static const int N = sizeof(lipo_mv)/sizeof(lipo_mv[0]);
int pct;
if (cell_mv <= lipo_mv[0]) {
pct = 0;
} else if (cell_mv >= lipo_mv[N-1]) {
pct = 100;
} else {
int i = 0;
while (i < N-2 && cell_mv >= lipo_mv[i+1]) i++;
pct = lipo_pct[i] + (cell_mv - lipo_mv[i]) *
(lipo_pct[i+1] - lipo_pct[i]) / (lipo_mv[i+1] - lipo_mv[i]);
}
g_battery_pct = pct;
oled_draw_header();
}
// ── Idle tracking and auto-sleep ──────────────────────────────────────
if (fwd == 0 || back == 0 || left == 0 || right == 0)
idle_ticks = 0;
else
idle_ticks++;
if (!g_sleep_mode && g_sleep_timeout_min > 0 &&
idle_ticks >= (uint32_t)g_sleep_timeout_min * 60 * 100) {
idle_ticks = 0;
if (settings_dirty) { settings_save(); settings_dirty = 0; settings_save_tick = 0; }
oled_set_contrast(SLEEP_CONTRAST);
led_off();
int ble_pre_auto = g_ble_enabled;
if (g_ble_enabled) { g_ble_enabled = 0; ble_adv_update(); }
esp_light_sleep_start();
// woke up — restore
oled_set_contrast(0xCF);
g_ble_enabled = ble_pre_auto;
if (g_ble_enabled) ble_adv_update();
breath_phase = 0.0f;
led_update_for_count(g_life, 255);
oled_draw_header();
changed = 1;
}
// ── Display dispatch ──────────────────────────────────────────────────
if (changed) {
switch (g_active_menu) {
case MENU_LIFE:
oled_draw_life();
break;
case MENU_CMDR:
if (!g_ble_enabled) {
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);
} else {
oled_draw_players();
}
break;
case MENU_COUNTERS:
oled_draw_list(counter_names, g_counters, NUM_COUNTERS, g_active_counter);
break;
case MENU_DICE:
oled_draw_dice();
break;
case MENU_SETTINGS:
oled_draw_settings();
break;
}
}
#ifdef DEBUG
if (changed || g_tick % 1000 == 0)
serial_print_state();
#endif
prev_fwd = fwd; prev_right = right; prev_left = left; prev_back = back;
vTaskDelay(pdMS_TO_TICKS(10));
}
}