#include "draw.h" #include "config.h" #include "game.h" #include "font.h" #include "state.h" #include "driver/ledc.h" #include "driver/i2c.h" #include "freertos/FreeRTOS.h" #include #include 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", "RESET", "RESET ALL", "GAME ID", "PLAYER NAME", "MENU HOLD MS", "LR HOLD MS", "FLIP DISPLAY", "DELTA TIMEOUT", "AUTO SLEEP", }; #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 // ── LED ─────────────────────────────────────────────────────────────────────── void led_init(void) { ledc_timer_config_t timer = { .speed_mode = LEDC_MODE_, .timer_num = LEDC_TIMER_SEL, .duty_resolution = LEDC_DUTY_RES_, .freq_hz = LEDC_FREQ_HZ_, .clk_cfg = LEDC_AUTO_CLK, }; ledc_timer_config(&timer); ledc_channel_config_t ch[] = { {.gpio_num=LED_R_GPIO,.speed_mode=LEDC_MODE_,.channel=LEDC_CHANNEL_0,.timer_sel=LEDC_TIMER_SEL,.duty=0,.hpoint=0}, {.gpio_num=LED_G_GPIO,.speed_mode=LEDC_MODE_,.channel=LEDC_CHANNEL_1,.timer_sel=LEDC_TIMER_SEL,.duty=0,.hpoint=0}, {.gpio_num=LED_B_GPIO,.speed_mode=LEDC_MODE_,.channel=LEDC_CHANNEL_2,.timer_sel=LEDC_TIMER_SEL,.duty=0,.hpoint=0}, }; for (int i = 0; i < 3; i++) ledc_channel_config(&ch[i]); } void led_update_for_count(int count, uint8_t scale) { 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 <= 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)(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) { i2c_master_write_to_device(I2C_PORT, OLED_ADDR, buf, len, pdMS_TO_TICKS(100)); } static void oled_cmd(uint8_t c) { uint8_t b[2]={0x00,c}; oled_write(b,2); } static void oled_write_page(int p, const uint8_t *row); void oled_init(void) { static const uint8_t seq[] = { 0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,0x20,0x00, 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); oled_cmd(0x22); oled_cmd(p); oled_cmd(p); uint8_t buf[OLED_WIDTH+1]; buf[0]=0x40; memcpy(buf+1, row, OLED_WIDTH); oled_write(buf, sizeof(buf)); } 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++) { 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]; for (int col = 0; col < ICON_W; col++) { pages[0][sx+col] = bg ^ icon[0][col]; pages[1][sx+col] = bg ^ icon[1][col]; } } // Slot 5 (cols 80-95): percentage text, page 0 // Slot 6 (cols 96-111): vertical battery icon, nub at top, fill from bottom if (g_battery_pct >= 0) { int show = (g_battery_pct >= BATT_LOW_PCT || (g_tick / 50) % 2 == 0); if (show) { // ── Percentage text, vertically centered (rows 4-11) ───────────── char pct_str[5]; int plen = snprintf(pct_str, sizeof(pct_str), "%d", g_battery_pct); int tx = 5 * SEG_W + (SEG_W - plen * 5) / 2; for (int ci = 0; ci < plen; ci++) { uint8_t c = (uint8_t)pct_str[ci]; if (c < FONT_BASE || c > FONT_MAX) continue; const uint8_t *glyph = font5x8[c - FONT_BASE]; for (int sc = 0; sc < 5; sc++, tx++) { if (tx < 0 || tx >= OLED_WIDTH) continue; pages[0][tx] &= ~(uint8_t)(glyph[sc] << 4); // font bits 0-3 → rows 4-7 pages[1][tx] &= ~(uint8_t)(glyph[sc] >> 4); // font bits 4-7 → rows 8-11 } } // ── Vertical battery icon ───────────────────────────────────────── // 1px inset from slot edges: body cols 2-13, rows 3-14. // Nub: cols 5-10, rows 1-2. // Inner: cols 3-12, rows 4-13 (10 rows). Fill rises from bottom. int fill_rows = g_battery_pct * 10 / 100; int fill_start = 14 - fill_rows; // first filled inner row uint8_t p0_fill = 0, p1_fill = 0; for (int r = 4; r <= 7; r++) if (r >= fill_start) p0_fill |= (1 << r); for (int r = 8; r <= 13; r++) if (r >= fill_start) p1_fill |= (1 << (r-8)); int bbase = 6 * SEG_W; for (int x = 0; x < 16; x++) { uint8_t p0 = 0, p1 = 0; if (x == 2 || x == 13) { p0 = 0xF8; p1 = 0x7F; // side borders rows 3-14 } else if (x >= 3 && x <= 12) { p0 = 0x08 | p0_fill; // top border (row 3) + fill p1 = 0x40 | p1_fill; // bottom border (row 14) + fill } if (x >= 5 && x <= 10) p0 |= 0x06; // nub rows 1-2 pages[0][bbase + x] &= ~p0; pages[1][bbase + x] &= ~p1; } } } for (int p = 0; p < HEADER_PAGES; p++) oled_write_page(p, pages[p]); } void oled_draw_centered(const char *str) { int len = strlen(str); int start = (OLED_WIDTH - len * CHAR_WIDTH) / 2; if (start < 0) start = 0; for (int p = HEADER_PAGES; p < OLED_PAGES; p++) { uint8_t row[OLED_WIDTH]; memset(row, 0, sizeof(row)); if (p >= START_PAGE && p < START_PAGE + CHAR_PAGES) { int char_page = p - START_PAGE; for (int i = 0; i < len; i++) { char c = str[i]; if (c < FONT_BASE || c > FONT_MAX) c = ' '; const uint8_t *glyph = font5x8[(uint8_t)c - FONT_BASE]; int col = start + i * CHAR_WIDTH; for (int sc = 0; sc < 5*SCALE; sc++) { int x = col + sc; if (x < 0 || x >= OLED_WIDTH) continue; uint8_t font_col = glyph[sc/SCALE]; uint8_t pixel_byte = 0; for (int b = 0; b < 8; b++) { int src_row = (char_page*8+b)/SCALE; if (src_row < 8 && ((font_col >> src_row) & 1)) pixel_byte |= (1<= base && 8*p+r < base+h) mask |= (1< FONT_MAX) c = ' '; const uint8_t *glyph = font5x8[c - FONT_BASE]; for (int sc = 0; sc < 5; sc++, x++) { for (int g = 0; g < 8; g++) { if ((glyph[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)); } } } } x++; } return x; } static void fill_cols(uint8_t pages[][OLED_WIDTH], int content_h, int base, int h, int x0, int x1) { for (int p = base/8; p <= (base+h-1)/8 && p < content_h/8; p++) { uint8_t mask = row_mask(p, base, h); for (int x = x0; x < x1 && x < OLED_WIDTH; x++) pages[p][x] |= mask; } } // 5×8 mini-icons (vertical-byte, bit0=top), same format as font5x8 columns. // Teardrop: pointed top, round bottom (for poison) static const uint8_t icon_mini_drop[5] = {0x3C, 0x7E, 0x7F, 0x7E, 0x3C}; // Lightning bolt: thin zigzag from center-top with multiple branches static const uint8_t icon_mini_bolt[5] = {0x44, 0x2A, 0x51, 0x0C, 0x10}; 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) { const int content_h = (OLED_PAGES - HEADER_PAGES) * 8; const int entry_h = 9; const int max_full = content_h / entry_h; int start = active - max_full / 2; if (start < 0) start = 0; if (start + max_full > count) start = count - max_full; if (start < 0) start = 0; uint8_t pages[OLED_PAGES - HEADER_PAGES][OLED_WIDTH]; memset(pages, 0, sizeof(pages)); for (int ei = 0; ei < max_full+1 && (start+ei) < count; ei++) { int entry = start + ei; int inv = (entry == active); int base = ei * entry_h; if (inv) pxbuf_fill(pages, content_h, base, entry_h); if (rows[entry].label && rows[entry].label[0]) pxbuf_str(pages, content_h, base, 2, rows[entry].label, inv); if (rows[entry].value[0]) { int vlen = (int)strlen(rows[entry].value); int rx = OLED_WIDTH - 2 - vlen*6; if (rx < 0) rx = 0; pxbuf_str(pages, content_h, base, rx, rows[entry].value, inv); if (inv && rows[entry].cursor >= 0 && rows[entry].cursor < vlen) { int cursor_x = rx + rows[entry].cursor * 6; for (int p = base/8; p <= (base+entry_h-1)/8 && p < content_h/8; p++) { uint8_t mask = row_mask(p, base, entry_h); for (int j = 0; j < 6 && cursor_x+j < OLED_WIDTH; j++) pages[p][cursor_x+j] &= ~mask; } char one[2] = { rows[entry].value[rows[entry].cursor], 0 }; pxbuf_str(pages, content_h, base, cursor_x, one, 0); } } } for (int p = 0; p < OLED_PAGES - HEADER_PAGES; p++) oled_write_page(HEADER_PAGES + p, pages[p]); } // ── Menu draw functions (thin wrappers that build rows and call oled_draw_rows) ── void oled_draw_list(const char **labels, const int *values, int count, int active) { oled_row_t rows[8]; int n = count < 8 ? count : 8; for (int i = 0; i < n; i++) { rows[i].label = labels[i]; rows[i].cursor = -1; snprintf(rows[i].value, sizeof(rows[i].value), "%d", values[i]); } oled_draw_rows(rows, n, active); } void oled_draw_players(void) { int total = MAX_BLE_PEERS + 1 + (!g_ble_enabled ? 1 : 0); oled_row_t rows[MAX_BLE_PEERS + 2]; rows[0].label = g_player_name; rows[0].cursor = -1; snprintf(rows[0].value, sizeof(rows[0].value), g_eliminated ? "ELIM" : "%d/%d", g_life, g_counters[0]); for (int pi = 0; pi < MAX_BLE_PEERS; pi++) { rows[pi+1].cursor = -1; if (g_peers[pi].active) { rows[pi+1].label = g_peers[pi].name; snprintf(rows[pi+1].value, sizeof(rows[pi+1].value), g_peers[pi].eliminated ? "ELIM" : "%d/%d/%d", g_peers[pi].life, g_peers[pi].poison, g_cmdr_damage[pi]); } else { rows[pi+1].label = ""; rows[pi+1].value[0] = 0; } } if (!g_ble_enabled) { rows[MAX_BLE_PEERS+1].label = "BLE OFF"; rows[MAX_BLE_PEERS+1].value[0] = 0; rows[MAX_BLE_PEERS+1].cursor = -1; } oled_draw_rows(rows, total, g_active_player); } void oled_draw_settings(void) { oled_row_t rows[NUM_SETTINGS]; for (int i = 0; i < NUM_SETTINGS; i++) { rows[i].label = setting_names[i]; rows[i].cursor = -1; } snprintf(rows[SET_BRIGHTNESS].value, sizeof(rows[0].value), "%d%%", g_brightness_pct); snprintf(rows[SET_START_LIFE].value, sizeof(rows[0].value), "%d", start_life_opts[g_start_life_index]); snprintf(rows[SET_NUM_OPP].value, sizeof(rows[0].value), "%d", g_num_opponents); memcpy(rows[SET_PLAYER_NAME].value, g_player_name, PLAYER_NAME_LEN); rows[SET_PLAYER_NAME].value[PLAYER_NAME_LEN] = 0; rows[SET_PLAYER_NAME].cursor = g_name_cursor; snprintf(rows[SET_BLE].value, sizeof(rows[0].value), "%s", g_ble_enabled ? "ON" : "OFF"); snprintf(rows[SET_GAME_ID].value, sizeof(rows[0].value), "%02X%02X", g_game_id[0], g_game_id[1]); 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); if (g_sleep_timeout_min == 0) snprintf(rows[SET_AUTO_SLEEP].value, sizeof(rows[0].value), "OFF"); else snprintf(rows[SET_AUTO_SLEEP].value, sizeof(rows[0].value), "%dmin", g_sleep_timeout_min); oled_draw_rows(rows, NUM_SETTINGS, g_active_setting); } void oled_draw_dice(void) { // 128px wide, labels start at x=2, 6px/char → 21 chars per row static const int WRAP_COLS = 21; static const int MAX_WRAP = 8; char wrap_lines[8][24]; int wrap_count = 0; if (g_dice_rolled) { const char *src = g_dice_csv; while (*src && wrap_count < MAX_WRAP) { int len = (int)strlen(src); if (len <= WRAP_COLS) { strncpy(wrap_lines[wrap_count], src, WRAP_COLS); wrap_lines[wrap_count][WRAP_COLS] = '\0'; wrap_count++; break; } // find last comma within WRAP_COLS chars to avoid mid-number breaks int cut = WRAP_COLS; for (int j = WRAP_COLS - 1; j > 0; j--) { if (src[j] == ',') { cut = j; break; } } strncpy(wrap_lines[wrap_count], src, cut); wrap_lines[wrap_count][cut] = '\0'; wrap_count++; src += cut; if (*src == ',') src++; } } oled_row_t rows[3 + 8]; int n = 3; rows[0].label = "NUM"; rows[0].cursor = -1; snprintf(rows[0].value, sizeof(rows[0].value), "%d", g_dice_num); rows[1].label = "SIDES"; rows[1].cursor = -1; snprintf(rows[1].value, sizeof(rows[1].value), "D%d", die_sides[g_dice_sides]); rows[2].label = "ROLL"; rows[2].cursor = -1; if (g_dice_rolled) snprintf(rows[2].value, sizeof(rows[2].value), "%d", g_dice_sum); else snprintf(rows[2].value, sizeof(rows[2].value), "---"); for (int i = 0; i < wrap_count; i++) { rows[n].label = wrap_lines[i]; rows[n].cursor = -1; rows[n].value[0] = '\0'; n++; } oled_draw_rows(rows, n, g_dice_item); } 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]); }