/* Wormhole Invaders                                */
/* CC BY-NC-SA Jeroen Brinkman                      */
/* Version 4-6-2026                                */

// =============================================================================
// Target  : Arduino Mega 2560 pro 
// Hardware: TM1637 digit  
//           LED Strip WS2815, DC12V, 120 LEDs, 2m (IP67)
//           3x game push butttons with built-in LED (blue / green / red) |
//           1x black push button 
// =============================================================================

#define VERSION  "R1 V30"
#define TEST           1         // Show Serial oputput
#include <FastLED.h>             // https://fastled.io/docs/
#include <TM1637TinyDisplay6.h>  // https://github.com/jasonacox/TM1637TinyDisplay
#include <EEPROM.h>              // https://docs.arduino.cc/learn/built-in-libraries/eeprom/

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  1. TEXTS & NAMES                                                         ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

// ── Display texts ────────────────────────────────────────────────────────────
#define TEXT_LEV_START  "START"  // 5 chars + 1 level number = 6 chars on display
#define TEXT_LEV_STOP   "STOP "  // 5 chars + 1 level number = 6 chars on display
#define TEXT_OVER       " OVER " // shown on elimination
#define TEXT_NAME       "WwORMmGAT INDRINGERS" // shown game name on start-up

// ── Players ──────────────────────────────────────────────────────────────────
#define NUM_PLAYERS_MAX  7   // max selectable players (fits in 1 byte bitmask)
const char PLAYER_NAMES[NUM_PLAYERS_MAX][7] = {
  "Juno  ", "SaaR  ", "ANNE  ", "SPRITE", "BLAST ", "TITAN ", "GAIA  ",
};

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  2. GAME DEFINITIONS                                                      ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

// ── General ──────────────────────────────────────────────────────────────────
#define NUM_LEVELS         9   // total number of levels (1-9)
#define COUNTDOWN         10   // seconds countdown before each player's turn
#define BULLET_LENGTH      2   // bullet length in pixels
#define BULLET_SPEED_MS   40   // bullet movement: ms per pixel step
#define FIRE_COOLDOWN_MS  30   // minimum ms between two shots (debounce safety)
#define METEOR_WIDTH       4   // meteor width in pixels (4 consecutive queue entries)
#define METEOR_HP          2   // hit points per meteor pixel (total: WIDTH × HP)

// ── Score values ─────────────────────────────────────────────────────────────
#define SCORE_ASTEROID     1   // asteroid destroyed (R/G/B, 1 button)
#define SCORE_PLANET       2   // planet destroyed (Y/C/M, 2 buttons)
#define SCORE_STAR         3   // star destroyed (white, 3 buttons — +1 per button)
#define SCORE_METEOR       1   // per meteor pixel destroyed
#define SCORE_BOSS_SEG     1   // per boss segment destroyed
#define SCORE_DIST_DIV     5   // distance bonus: empty pixels / this value

// ── Level table ──────────────────────────────────────────────────────────────
//  Columns:
//    gap    = off-screen pixels between waves (not before the first wave)
//    waves  = number of waves per level (0 = boss-only, skip wave phase entirely)
//    ast    = asteroids  (R/G/B, 1 button, +1 pt each)
//    star   = stars      (white, 3 buttons, +3 pt, always last in wave)
//    planet = planets    (Y/C/M, 2 buttons, +2 pt each)
//    meteor = primary-colour meteors  (METEOR_WIDTH px, METEOR_HP per px, +1 pt/px)
//    cmet   = mixed-colour meteors
//    speed  = wave speed (1=slow, 2=medium, 3=fast)
//    bTyp   = boss type  (0=none, 1=Rampart, 2=Sentinel, 3=Prism)
//    bDif   = boss difficulty (1=easy, 2=medium, 3=hard; 0 is invalid, clamped to 1)

const uint16_t LEVELS[NUM_LEVELS][10] = {
  // gap waves  ast  star  pln  met cmet spd bTyp bDif
  {  10,   1,   10,   0,   0,   0,   0,  1,   0,   0 },  // level 1  basics only
  {  10,   2,   10,   0,   0,   0,   0,  1,   0,   0 },  // level 2  simple wave
  {  10,   3,   20,   0,   5,   2,   0,  2,   0,   0 },  // level 3  complex wave
  {  12,   0,   30,   1,  10,   0,   3,  3,   1,   1 },  // level 4  RAMPART
  {  12,   0,    0,   0,   4,   1,   0,  2,   2,   1 },  // level 5  SENTINEL
  {  14,   0,    0,   0,   5,   1,   0,  2,   3,   1 },  // level 6  PRISM
  {  14,   0,    0,   0,   7,   1,   1,  3,   1,   2 },  // level 7  RAMPART difficult
  {  16,   0,    0,   1,   9,   1,   1,  3,   2,   2 },  // level 8  SENTINEL difficult
  {  16,   5,   15,   3,  11,   3,   2,  3,   3,   2 },  // level 9  ALL OUT
};

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  3. GAME SPEEDS                                                           ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

const uint16_t WAVE_SPEED_MS[4] = { 800, 800, 400, 150 };  // ms/pixel per speed level (index 0 unused)

// BD_MOVE : boss advance per 60 loop iterations (integer accumulator, no float)
// BD_HP   : hit points per boss segment
// BD_FREQ : shot/charge interval in × 100 ms (higher = slower)
// BD_BURST: projectiles in a Rampart rage burst or Prism phase burst

//                                  move  hp  freq burst
const uint8_t RAMPART_D[3][4]  = { {  2,   1,  50,   2 },   // diff 1 easy
                                    {  4,   2,  30,   4 },   // diff 2 medium
                                    {  6,   3,  15,   7 } }; // diff 3 hard

const uint8_t SENTINEL_D[3][4] = { {  3,   1,  50,   0 },   // diff 1 easy
                                    {  6,   2,  30,   0 },   // diff 2 medium
                                    { 10,   3,  15,   0 } }; // diff 3 hard
const uint8_t SENTINEL_MK[3]   = { 85, 55, 30 };

const uint8_t PRISM_D[3][4]    = { {  3,   1,  80,   2 },   // diff 1 easy
                                    {  5,   2,  50,   4 },   // diff 2 medium
                                    {  8,   3,  25,   7 } }; // diff 3 hard
const uint8_t PRISM_MK[2]      = { 66, 50 };

// ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
// ▓▓  DO NOT CHANGE BELOW THIS LINE UNLESS YOU KNOW WHAT YOU ARE DOING  ▓▓
// ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  4. HARDWARE                                                              ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

// ── Serial ───────────────────────────────────────────────────────────────────
#define BAUD           115200   // serial monitor baud rate

// ── LED strip ────────────────────────────────────────────────────────────────
#define NUM_LEDS          120   // total LEDs on WS2815 strip
#define LED_OFFSET          1   // LED 0 = level-indicator LED, game starts at LED 1
#define GAME_LEDS        (NUM_LEDS - LED_OFFSET)  // usable game pixels (119)
                                                                         
#define STRIP_BRIGHT       80   // FastLED global brightness 0-255 (~30%)
#define POWER_VOLTS        12   // strip voltage for power limiter
#define POWER_MA         3000   // max current in mA for FastLED limiter
#define LEVEL_LED_COLOR  CRGB(20, 0, 0)  // colour of the level-indicator LEDs; change freely

// ── Display (TM1637 6-digit) ─────────────────────────────────────────────────                                    
#define DISP_NORMAL         2   // default display brightness 0-7
#define DISP_BRIGHT         6   // highlighted display brightness 0-7

// ── I/O pins ──────────────────────────────────
#define PIN_LED_R           6   // red button LED
#define PIN_LED_G           8   // green button LED
#define PIN_LED_B          10   // blue button LED
#define PIN_CLK            18   // TM1637 clock pin
#define PIN_DIO            20   // TM1637 data pin
#define PIN_STRIP          22   // data pin to strip (via 390Ω resistor)
#define PIN_PUSH_R         24   // red push button   (swapped vs v12: physical wiring)
#define PIN_PUSH_G         26   // green push button (swapped vs v12: physical wiring)
#define PIN_PUSH_B         28   // blue push button
#define PIN_PUSH           30   // black push button

// ── Timing constants ─────────────────────────────────────────────────────────
#define PLAYER_SHOW_MS     (COUNTDOWN * 1000UL)  // countdown ribbon duration
#define SIMULTANEOUS_MS    50   // combo window for multi-button detection
#define RAINBOW_STEP_MS    30   // ms per rainbow animation frame
#define RAINBOW_WAVES      90   // total frames in rainbow sweep
#define LEVEL_DONE_MS    2000   // green flash duration on level clear
#define FLASH_HALF_MS     150   // half-cycle of flash animation
#define FLASH_COUNT         3   // number of flash cycles
#define ELIM_PAUSE_MS    2000   // "OVER" display duration after elimination
#define HS_SHOW_MS       3000   // score bar duration after elimination
#define STAND_NAME_MS    2000   // name display per player in standings
#define STAND_SCORE_MS   2000   // score display per player in standings
#define WINNER_SHOW_MS   5000   // winner name display before rainbow
#define DISP_BLINK_MS      80   // display dim duration on wrong hit
#define STAR_FLASH_MS      50   // per-step of star kill LED flash
#define SOLO_SCORE_MAX    500   // max score for solo score bar fill
#define RESET_LONG_MS    3000   // ms hold black button to restart
#define RST_DEBOUNCE_MS   150   // ms stable window for reset button debounce
#define MENU_DEBOUNCE_MS   30   // ms stable window for menu button debounce (R/G/B)
#define NAME_BLINK_MS     500   // name blink period during announcement
#define BTN_FLICKER_MS    300   // button LED rotation period during announcement

// ── EEPROM layout ────────────────────────────────────────────────────────────
// Scores occupy bytes 4 … (4 + NUM_LEVELS × NUM_PLAYERS_MAX × 2 − 1) = 4…129.
// Magic address must be outside that range.
#define EEPROM_START_LVL    0   // addr: default start level (1 byte)
#define EEPROM_STOP_LVL     1   // addr: default stop level  (1 byte)
#define EEPROM_PLAYERS      2   // addr: player selection bitmask (1 byte)
#define EEPROM_SCORES       4   // addr: high scores (2 bytes × level × player)
#define EEPROM_MAGIC_ADDR 150   // addr: magic byte — safely beyond score range (130+)
#define EEPROM_MAGIC_VAL 0x4C   // change triggers EEPROM re-init on boot

#define MAKER "Jeroen Brinkman"

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  5. Types                                                                 ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

#define BD_MOVE         0   // boss advance per 60 loop iterations (integer accumulator, no float)
#define BD_HP           1   // hit points per boss segment
#define BD_FREQ         2   // shot/charge interval in × 100 ms (higher = slower)
#define BD_BURST        3   // projectiles in a Rampart rage burst or Prism phase burst

#define LVL_GAP         0   // off-screen pixels between waves (skipped before wave 1)
#define LVL_WAVES       1   // number of waves per level (0 = boss-only, skip wave phase)
#define LVL_ASTEROID    2   // asteroids (R/G/B, 1 button)
#define LVL_STAR        3   // stars (white, 3 buttons)
#define LVL_PLANET      4   // planets (Y/C/M, 2 buttons)
#define LVL_METEOR      5   // primary-colour meteors
#define LVL_CMETEOR     6   // mixed-colour meteors
#define LVL_SPEED       7   // wave speed 1-3
#define LVL_BOSS_TYPE   8   // boss type 0-3
#define LVL_BOSS_DIFF   9   // boss difficulty 1-3
#define LVL_COLS       10   // total columns in level table

#define COLOR_NONE      0
#define COLOR_RED       1
#define COLOR_GREEN     2
#define COLOR_BLUE      3
#define COLOR_YELLOW    4
#define COLOR_CYAN      5
#define COLOR_MAGENTA   6
#define COLOR_WHITE     7

#define TYPE_ASTEROID   0   // single pixel, primary colour  (R/G/B)
#define TYPE_PLANET     1   // single pixel, mixed colour    (Y/C/M)
#define TYPE_STAR       2   // single white pixel, always last in wave
#define TYPE_METEOR     3   // METEOR_WIDTH consecutive pixels, same colour

// [improvement 7] MAX_ENEMIES capped at MAX_WAVE_BUILD — buildWave() can never
// produce more than MAX_WAVE_BUILD entries, so the extra 40 slots were wasted SRAM.
#define MAX_WAVE_BUILD 80
#define MAX_ENEMIES    MAX_WAVE_BUILD
#define MAX_BULLETS    20
#define MAX_BOSS_SEG   15
#define MAX_BOSS_PROJ  20

struct Enemy    { uint8_t color, type, hp; };
struct Bullet   { int16_t pos; uint8_t color; bool active; };
struct BossSeg  { uint8_t color, hp; bool active; };
// [improvement 3] BossProj.pos is int16_t — projectiles move in whole pixels,
// matching player bullets. float was unnecessary on an 8-bit MCU.
struct BossProj { int16_t pos; uint8_t color; };
enum BossState  { BS_ADVANCE, BS_RAGE, BS_CHARGE, BS_BARRAGE, BS_VULNERABLE,
                  BS_PHASE_CHANGE, BS_BURST, BS_COOLDOWN };

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  6. Globals                                                               ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

CRGB leds[NUM_LEDS];
TM1637TinyDisplay6 disp(PIN_CLK, PIN_DIO);

enum State {
  ST_SEL_PLAYERS, ST_SEL_START_LVL, ST_SEL_STOP_LVL, ST_ANNOUNCE,
  ST_WAVE, ST_BOSS,
  ST_LEVEL_DONE, ST_ELIMINATED,
  ST_STANDINGS, ST_WINNER
};
State state;

uint8_t  plrMask = 0, plrCount = 0;
int16_t  plrScores[NUM_PLAYERS_MAX];     // cumulative score across levels
bool     plrActive[NUM_PLAYERS_MAX];

uint8_t  plrOrder[NUM_PLAYERS_MAX];      // play order (randomised once at start)
uint8_t  roundLevel = 0;
uint8_t  roundTurnIdx = 0;
uint8_t  curPlayer = 0;
int16_t  turnScore = 0;                  // score for THIS level only
int16_t  lastFront = 0;                  // boss pixel position on kill → distance bonus
uint8_t  startLvl = 0;
uint8_t  stopLvl  = 0;                  // last level to play (inclusive); always >= startLvl

// ── Wave tracking ─────────────────────────────────────────────────────────────
uint8_t  waveNum         = 0;     // current wave within this level (0-based)
uint8_t  wavesTotal      = 0;     // total waves for this level (from LVL_WAVES)
// [improvement 4] explicit flag replaces the lastFront=0 side-channel signal
bool     waveDistDone    = false; // true once per-wave distance bonus has been accumulated

Enemy    enemies[MAX_ENEMIES];
uint8_t  enemyCount = 0;
// [improvement 3] enemyFront is int16_t — the wave always moves in whole pixels.
int16_t  enemyFront = 0;

Bullet   bullets[MAX_BULLETS];

BossSeg  bossSegs[MAX_BOSS_SEG];
uint8_t  bossSegCount = 0;
// [improvement 3] bossFront uses an integer pixel position + uint8_t accumulator
// instead of float. Accumulator counts up to 60; each time it hits 60 the boss
// advances one pixel. BD_MOVE pixels of progress per 60 loop iterations.
int16_t  bossFrontPx  = 0;
uint8_t  bossMoveAcc  = 0;
BossProj bossProjs[MAX_BOSS_PROJ];
uint8_t  bossProjCount = 0;
// [improvement 2] explicit flag replaces the bossProjCount=255 sentinel hack
bool     bossBasReached = false;
uint8_t  bossType = 0, bossDiff = 0;
BossState bossState = BS_ADVANCE;
uint32_t tBossAct = 0;
uint8_t  bossWrongHits = 0, bossRageBurst = 0;
uint8_t  bossSentSec = 0, bossSentShots = 0, bossSentTarget = 0, bossSentColor = 0;
uint8_t  bossPrismPh = 0, bossBurstFired = 0;

uint32_t tInvMove = 0, tBulMove = 0, tBossProjMove = 0, tState = 0;

bool     btnReleased  = true;   // all buttons must be released before next input
bool     comboWaiting = false;  // waiting for simultaneous window to close
uint32_t tCombo       = 0;      // when combo window started
uint32_t tLastFire    = 0;      // last shot timestamp (cooldown)

uint32_t dispDimUntil  = 0;
uint8_t  starFlashStep = 0;
uint32_t tStarFlash    = 0;

uint8_t  menuCursor    = 0;
uint8_t  standOrder[NUM_PLAYERS_MAX];
int16_t  prevDispScore  = -9999;
uint8_t  prevStandIdx   = 255;
bool     prevStandPhase = false;

bool     elimHsDrawn = false;   // one-shot flag for score bar in elimination

bool     shotR = false, shotG = false, shotB = false;  // combo accumulator

// [improvement 5] Single reset button state machine.
// tickRst() is called once per loop(). pressRst() and checkLongReset() consume
// the queued event — no separate debounce state machines racing each other.
enum RstEvent { RST_NONE, RST_SHORT, RST_LONG };
RstEvent rstEvent    = RST_NONE;
bool     rstRawPrev  = false;
bool     rstStable   = false;
bool     rstHeld     = false;
uint32_t tRstEdge    = 0;
uint32_t tRstPress   = 0;

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  7. Utility                                                               ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

// [improvement 8] isSolo() — replaces scattered plrCount == 1 literals
inline bool isSolo() { return plrCount == 1; }

CRGB colorRGB(uint8_t c) {
  switch (c) {
    case COLOR_RED:     return CRGB(255,   0,   0);
    case COLOR_GREEN:   return CRGB(  0, 255,   0);
    case COLOR_BLUE:    return CRGB(  0,   0, 255);
    case COLOR_YELLOW:  return CRGB(255, 255,   0);
    case COLOR_CYAN:    return CRGB(  0, 255, 255);
    case COLOR_MAGENTA: return CRGB(255,   0, 255);
    case COLOR_WHITE:   return CRGB(255, 255, 255);
    default:            return CRGB::Black;
  }
}

CRGB colorHP(uint8_t c, uint8_t hp) {
  CRGB b = colorRGB(c);
  return hp >= 3 ? b : hp == 2 ? b.nscale8(170) : hp == 1 ? b.nscale8(80) : CRGB::Black;
}

uint8_t randPrimary() { return random(1, 4); }   // random primary colour (R/G/B)
uint8_t randMixed()   { return random(4, 7); }   // random mixed colour   (Y/C/M)

void setPixel(int p, CRGB c) {
  int i = p + LED_OFFSET;
  if (i >= LED_OFFSET && i < NUM_LEDS) leds[i] = c;
}

void btnLEDs(bool r, bool g, bool b) {
  digitalWrite(PIN_LED_R, r); digitalWrite(PIN_LED_G, g); digitalWrite(PIN_LED_B, b);
}

void enterState(State s) { state = s; tState = millis(); }

void renderLevelLEDs() {
  uint8_t n = min((uint8_t)(roundLevel + 1), (uint8_t)NUM_LEDS);
  for (uint8_t i = 0; i < n; i++) leds[i] = LEVEL_LED_COLOR;
}

uint16_t waveSpeedMs() {
  uint8_t li  = min(roundLevel, (uint8_t)(NUM_LEVELS - 1));
  uint8_t spd = constrain((uint8_t)LEVELS[li][LVL_SPEED], (uint8_t)1, (uint8_t)3);
  return WAVE_SPEED_MS[spd];
}

const uint8_t* bossParams() {
  uint8_t d = min((uint8_t)(bossDiff - 1), (uint8_t)2);
  return bossType == 1 ? RAMPART_D[d] : bossType == 2 ? SENTINEL_D[d] : PRISM_D[d];
}

uint8_t countActivePlayers() {
  uint8_t n = 0;
  for (uint8_t i = 0; i < plrCount; i++) if (plrActive[plrOrder[i]]) n++;
  return n;
}

void showStartLvlOnDisp(uint8_t lvl) {
  char buf[7]; snprintf(buf, 7, "%s%1d", TEXT_LEV_START, lvl + 1); disp.showString(buf);
}
void showStopLvlOnDisp(uint8_t lvl) {
  char buf[7]; snprintf(buf, 7, "%s%1d", TEXT_LEV_STOP,  lvl + 1); disp.showString(buf);
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  8. EEPROM — high score per level per player                              ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

void eepromInit() {
  if (EEPROM.read(EEPROM_MAGIC_ADDR) != EEPROM_MAGIC_VAL) {
    EEPROM.update(EEPROM_START_LVL, 0);
    EEPROM.update(EEPROM_STOP_LVL,  0);
    EEPROM.update(EEPROM_PLAYERS, 0x01);
    for (int i = EEPROM_SCORES; i < EEPROM_SCORES + NUM_LEVELS * NUM_PLAYERS_MAX * 2; i++)
      EEPROM.update(i, 0);
    EEPROM.update(EEPROM_MAGIC_ADDR, EEPROM_MAGIC_VAL);
    #if TEST
    Serial.println(F("EEPROM init (per-level HS)"));
    #endif
  }
}

uint8_t  eeReadStartLvl()           { return min(EEPROM.read(EEPROM_START_LVL), (uint8_t)(NUM_LEVELS - 1)); }
void     eeWriteStartLvl(uint8_t v) { EEPROM.update(EEPROM_START_LVL, v); }
uint8_t  eeReadStopLvl()            { return min(EEPROM.read(EEPROM_STOP_LVL),  (uint8_t)(NUM_LEVELS - 1)); }
void     eeWriteStopLvl(uint8_t v)  { EEPROM.update(EEPROM_STOP_LVL,  v); }
uint8_t  eeReadPlr()                { return EEPROM.read(EEPROM_PLAYERS); }
void     eeWritePlr(uint8_t v)      { EEPROM.update(EEPROM_PLAYERS, v); }

uint16_t eeReadHS(uint8_t level, uint8_t player) {
  int a = EEPROM_SCORES + (level * NUM_PLAYERS_MAX + player) * 2;
  return EEPROM.read(a) | ((uint16_t)EEPROM.read(a + 1) << 8);
}
void eeWriteHS(uint8_t level, uint8_t player, uint16_t score) {
  int a = EEPROM_SCORES + (level * NUM_PLAYERS_MAX + player) * 2;
  EEPROM.update(a, score & 0xFF);
  EEPROM.update(a + 1, score >> 8);
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  9. Input                                                                 ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

bool rawR()   { return !digitalRead(PIN_PUSH_R); }
bool rawG()   { return !digitalRead(PIN_PUSH_G); }
bool rawB()   { return !digitalRead(PIN_PUSH_B); }
bool rawRst() { return !digitalRead(PIN_PUSH); }

// Menu button debounce — same stable-window pattern as tickRst(), but for R/G/B.
// rawR/G/B() are left untouched so pollGameInput() continues to work as before.
bool     mbRawPrev[3] = { false, false, false };
bool     mbStable[3]  = { false, false, false };
uint32_t tMbEdge[3]   = { 0, 0, 0 };

bool menuPress(uint8_t btn) {
  bool raw = (btn == 0) ? rawR() : (btn == 1) ? rawG() : rawB();
  if (raw != mbRawPrev[btn]) { mbRawPrev[btn] = raw; tMbEdge[btn] = millis(); }
  if (millis() - tMbEdge[btn] < MENU_DEBOUNCE_MS) return false;  // still bouncing
  bool prev = mbStable[btn];
  mbStable[btn] = raw;
  return raw && !prev;   // rising edge on stable signal
}

bool pressR() { return menuPress(0); }
bool pressG() { return menuPress(1); }
bool pressB() { return menuPress(2); }

// [improvement 5] Single reset button state machine — called once per loop()
// before any state handler. Produces RST_SHORT (quick press+release) or
// RST_LONG (held ≥ RESET_LONG_MS). Callers consume via pressRst() / checkLongReset().
void tickRst() {
  bool raw = rawRst();
  if (raw != rstRawPrev) { rstRawPrev = raw; tRstEdge = millis(); }
  if (millis() - tRstEdge < RST_DEBOUNCE_MS) return;   // wait for stable signal
  bool cur = raw;   // debounced level
  if (cur && !rstStable) {
    // Rising edge: button pressed
    rstHeld = true; tRstPress = millis();
  } else if (!cur && rstStable && rstHeld) {
    // Falling edge: button released — short press if not already fired as long
    rstEvent = RST_SHORT; rstHeld = false;
  }
  if (rstHeld && millis() - tRstPress >= RESET_LONG_MS) {
    rstEvent = RST_LONG; rstHeld = false;
  }
  rstStable = cur;
}

bool pressRst() {
  if (rstEvent == RST_SHORT) { rstEvent = RST_NONE; return true; }
  return false;
}
bool checkLongReset() {
  if (rstEvent == RST_LONG)  { rstEvent = RST_NONE; return true; }
  return false;
}

// Game input: dual mode
//   Primary-only levels (no planets/stars/boss): fire immediately on press
//   Mixed levels: wait SIMULTANEOUS_MS to detect multi-button combos
// Both modes: require full release between shots; enforce FIRE_COOLDOWN_MS
bool levelNeedsCombo() {
  uint8_t li = min(roundLevel, (uint8_t)(NUM_LEVELS - 1));
  return LEVELS[li][LVL_PLANET] > 0 || LEVELS[li][LVL_STAR] > 0 || bossType > 0;
}

uint8_t pollGameInput() {
  uint32_t now = millis();
  bool r = rawR(), g = rawG(), b = rawB();

  if (!(r || g || b)) { btnReleased = true; comboWaiting = false; return COLOR_NONE; }
  if (!btnReleased)   return COLOR_NONE;
  if (now - tLastFire < FIRE_COOLDOWN_MS) return COLOR_NONE;

  if (!levelNeedsCombo()) {
    btnReleased = false; tLastFire = now;
    if (r) return COLOR_RED;
    if (g) return COLOR_GREEN;
    if (b) return COLOR_BLUE;
    return COLOR_NONE;
  }

  if (!comboWaiting) {
    comboWaiting = true; tCombo = now;
    shotR = r; shotG = g; shotB = b;
    return COLOR_NONE;
  }
  if (r) shotR = true;
  if (g) shotG = true;
  if (b) shotB = true;
  if (now - tCombo < SIMULTANEOUS_MS) return COLOR_NONE;

  comboWaiting = false; btnReleased = false; tLastFire = now;
  if (shotR && shotG && shotB) return COLOR_WHITE;
  if (shotR && shotG)          return COLOR_YELLOW;
  if (shotG && shotB)          return COLOR_CYAN;
  if (shotR && shotB)          return COLOR_MAGENTA;
  if (shotR) return COLOR_RED;
  if (shotG) return COLOR_GREEN;
  if (shotB) return COLOR_BLUE;
  return COLOR_NONE;
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  10. Rendering                                                            ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

void renderBullets() {
  for (uint8_t b = 0; b < MAX_BULLETS; b++) {
    if (!bullets[b].active) continue;
    for (uint8_t p = 0; p < BULLET_LENGTH; p++) {
      int px = bullets[b].pos - p;
      if (px >= 0 && px < GAME_LEDS) setPixel(px, colorRGB(bullets[b].color));
    }
  }
}

void renderWave() {
  fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs();
  uint8_t dz = min((uint8_t)(roundLevel + 1), (uint8_t)GAME_LEDS);
  if (enemyCount > 0 && enemyFront < dz)
    for (uint8_t i = 0; i < dz; i++) setPixel(i, CRGB(30, 30, 30));
  for (uint8_t i = 0; i < enemyCount; i++) {
    int pos = enemyFront + i;
    if (pos >= 0 && pos < GAME_LEDS)
      setPixel(pos, enemies[i].type == TYPE_METEOR
                    ? colorHP(enemies[i].color, enemies[i].hp)
                    : colorRGB(enemies[i].color));
  }
  renderBullets(); FastLED.show();
}

void renderBoss() {
  fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs();
  if (bossType == 2)
    for (uint8_t m = 0; m < 3; m++) {
      int mp = (int)((uint32_t)GAME_LEDS * SENTINEL_MK[m] / 100);
      if (mp < bossFrontPx) setPixel(mp, CRGB(50, 0, 0));
    }
  if (bossType == 3)
    for (uint8_t m = 0; m < 2; m++) {
      if (m >= bossPrismPh) {
        int mp = (int)((uint32_t)GAME_LEDS * PRISM_MK[m] / 100);
        setPixel(mp, CRGB(50, 0, 0));
      }
    }
  for (uint8_t i = 0; i < bossSegCount; i++) {
    int pos = bossFrontPx + i;
    if (pos < 0 || pos >= GAME_LEDS) continue;
    CRGB c;
    if      (bossType == 1 && bossState == BS_RAGE)
      c = ((millis() / 50) % 2 == 0) ? CRGB::White : colorRGB(bossSegs[i].color);
    else if (bossType == 2 && !bossSegs[i].active)
      c = CRGB(34, 34, 34);
    else if (bossType == 3 && bossState == BS_PHASE_CHANGE)
      c = ((millis() / 100) % 2 == 0) ? CRGB::White : CRGB(80, 80, 80);
    else
      c = colorHP(bossSegs[i].color, bossSegs[i].hp);
    setPixel(pos, c);
  }
  for (uint8_t i = 0; i < bossProjCount; i++)
    setPixel(bossProjs[i].pos, colorRGB(bossProjs[i].color));
  renderBullets(); FastLED.show();
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  11. Wave building                                                        ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

static Enemy waveBuf[MAX_WAVE_BUILD];

uint8_t buildWave(uint8_t lv) {
  uint8_t li = min(lv, (uint8_t)(NUM_LEVELS - 1)), n = 0;

  for (uint8_t i = 0; i < LEVELS[li][LVL_ASTEROID] && n < MAX_WAVE_BUILD; i++)
    waveBuf[n++] = { randPrimary(), TYPE_ASTEROID, 1 };
  for (uint8_t i = 0; i < LEVELS[li][LVL_PLANET] && n < MAX_WAVE_BUILD; i++)
    waveBuf[n++] = { randMixed(), TYPE_PLANET, 1 };
  for (uint8_t t = 0; t < LEVELS[li][LVL_METEOR] && n + METEOR_WIDTH <= MAX_WAVE_BUILD; t++) {
    uint8_t c = randPrimary();
    for (uint8_t p = 0; p < METEOR_WIDTH; p++) waveBuf[n++] = { c, TYPE_METEOR, METEOR_HP };
  }
  for (uint8_t t = 0; t < LEVELS[li][LVL_CMETEOR] && n + METEOR_WIDTH <= MAX_WAVE_BUILD; t++) {
    uint8_t c = randMixed();
    for (uint8_t p = 0; p < METEOR_WIDTH; p++) waveBuf[n++] = { c, TYPE_METEOR, METEOR_HP };
  }

  // Fisher-Yates shuffle — guard against n==0 (uint8_t i = 0-1 wraps to 255)
  if (n > 1) {
    for (uint8_t i = n - 1; i > 0; i--) {
      uint8_t j = random(0, i + 1);
      Enemy tmp = waveBuf[i]; waveBuf[i] = waveBuf[j]; waveBuf[j] = tmp;
    }
  }

  // Prevent METEOR_WIDTH consecutive same-colour non-meteor entries after shuffle;
  // they would be visually indistinguishable from a meteor.
  // [improvement 6] TYPE_STAR check removed — stars are appended after this loop
  // and can never appear in waveBuf at this point.
  for (uint8_t i = METEOR_WIDTH - 1; i < n; i++) {
    if (waveBuf[i].type == TYPE_METEOR) continue;
    bool run = true;
    for (uint8_t k = 1; k < METEOR_WIDTH && run; k++) {
      if (waveBuf[i - k].type == TYPE_METEOR || waveBuf[i - k].color != waveBuf[i].color)
        run = false;
    }
    if (!run) continue;
    bool isMixed = (waveBuf[i].type == TYPE_PLANET);
    uint8_t c = waveBuf[i].color;
    do { c = isMixed ? randMixed() : randPrimary(); } while (c == waveBuf[i].color);
    waveBuf[i].color = c;
  }

  // Stars always go last
  for (uint8_t i = 0; i < LEVELS[li][LVL_STAR] && n < MAX_WAVE_BUILD; i++)
    waveBuf[n++] = { COLOR_WHITE, TYPE_STAR, 1 };
  return n;
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  12. Wave (enemy train) logic                                             ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

void growEnemy(uint8_t c) {
  if (enemyCount >= MAX_ENEMIES) return;
  for (int i = enemyCount; i > 0; i--) enemies[i] = enemies[i - 1];
  enemies[0] = { c, TYPE_ASTEROID, 1 };
  enemyCount++;
  enemyFront--;
  turnScore -= SCORE_ASTEROID;   // always deduct — negative score is allowed during play
  dispDimUntil = millis() + DISP_BLINK_MS;
}

void fireBullet(uint8_t c) {
  for (uint8_t b = 0; b < MAX_BULLETS; b++)
    if (!bullets[b].active) { bullets[b] = { 0, c, true }; return; }
}

void moveBullets() {
  for (uint8_t b = 0; b < MAX_BULLETS; b++) {
    if (!bullets[b].active) continue;
    if (++bullets[b].pos >= GAME_LEDS) bullets[b].active = false;
  }
}

bool waveCollisions() {
  for (uint8_t b = 0; b < MAX_BULLETS; b++) {
    if (!bullets[b].active || !enemyCount) continue;
    if (bullets[b].pos < enemyFront) continue;
    int hi = bullets[b].pos - enemyFront;
    if (hi >= enemyCount) hi = enemyCount - 1;
    // hi >= 0 guaranteed: bullets[b].pos >= enemyFront
    bullets[b].active = false;
    if (bullets[b].color == enemies[hi].color) {
      uint8_t ht = enemies[hi].type;
      if (--enemies[hi].hp <= 0) {
        int8_t pts = (ht == TYPE_STAR)   ? SCORE_STAR   :
                     (ht == TYPE_PLANET) ? SCORE_PLANET :
                     (ht == TYPE_METEOR) ? SCORE_METEOR : SCORE_ASTEROID;
        turnScore += pts;
        for (int i = hi; i < enemyCount - 1; i++) enemies[i] = enemies[i + 1];
        enemyCount--;
        if (hi == 0) enemyFront++;
        if (ht == TYPE_STAR) { starFlashStep = 1; tStarFlash = millis(); }
      }
    } else {
      growEnemy(bullets[b].color);
    }
  }
  return (enemyCount > 0 && enemyFront < 0);
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  13. Boss logic                                                           ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

void spawnBossProj(uint8_t c) {
  if (bossProjCount < MAX_BOSS_PROJ) bossProjs[bossProjCount++] = { bossFrontPx, c };
}

void initBoss() {
  const uint8_t* p = bossParams();
  bossState       = BS_ADVANCE;
  bossFrontPx     = GAME_LEDS - 1;
  bossMoveAcc     = 0;
  bossProjCount   = 0;
  bossBasReached  = false;   // [improvement 2]
  bossWrongHits   = 0; bossRageBurst  = 0;
  bossSentSec     = 0; bossSentShots  = 0;
  bossPrismPh     = 0; bossBurstFired = 0;
  tBossAct        = millis();
  if (bossType == 1) {
    bossSegCount = 9;
    for (uint8_t i = 0; i < 9; i++)
      bossSegs[i] = { (uint8_t)((i / 3 == 1) ? COLOR_BLUE : COLOR_GREEN), p[BD_HP], true };
  } else if (bossType == 2) {
    bossSegCount = 9;
    for (uint8_t i = 0; i < 9; i++) bossSegs[i] = { 0, p[BD_HP], false };
  } else {
    bossSegCount = 15;
    for (uint8_t i = 0; i < 15; i++) bossSegs[i] = { randMixed(), p[BD_HP], true };
  }
}

// [improvement 3] Boss movement uses integer accumulator instead of float math.
// accumulatorAdvanceBoss() is called from updateBoss() when the boss should move.
void accumulatorAdvanceBoss(uint8_t amount) {
  bossMoveAcc += amount;
  while (bossMoveAcc >= 60) { bossMoveAcc -= 60; bossFrontPx--; }
}

void moveBossProjs() {
  uint32_t now = millis();
  if (now - tBossProjMove < BULLET_SPEED_MS) return;
  tBossProjMove = now;
  for (int i = bossProjCount - 1; i >= 0; i--) {
    bossProjs[i].pos--;
    // [improvement 2] set named flag instead of abusing bossProjCount=255
    if (bossProjs[i].pos < 0) { bossBasReached = true; return; }
  }
}

bool bossCollisions() {
  // Pass 1: player bullets vs incoming boss projectiles
  for (uint8_t b = 0; b < MAX_BULLETS; b++) {
    if (!bullets[b].active) continue;
    for (int p = bossProjCount - 1; p >= 0; p--) {
      if (abs(bullets[b].pos - bossProjs[p].pos) < 2) {
        if (bullets[b].color == bossProjs[p].color) {
          for (int j = p; j < bossProjCount - 1; j++) bossProjs[j] = bossProjs[j + 1];
          bossProjCount--;
        }
        bullets[b].active = false; break;
      }
    }
  }
  // Pass 2: player bullets vs boss body segments
  for (uint8_t b = 0; b < MAX_BULLETS; b++) {
    if (!bullets[b].active || !bossSegCount) continue;
    if (bullets[b].pos < bossFrontPx) continue;
    int hi = bullets[b].pos - bossFrontPx;
    if (hi >= bossSegCount) hi = bossSegCount - 1;
    // hi >= 0 guaranteed: bullets[b].pos >= bossFrontPx
    bool canHit = bossSegs[hi].active &&
                  !(bossType == 3 && bossState == BS_PHASE_CHANGE);
    bullets[b].active = false;
    if (!canHit) continue;
    if (bullets[b].color == bossSegs[hi].color) {
      if (--bossSegs[hi].hp <= 0) {
        for (int j = hi; j < bossSegCount - 1; j++) bossSegs[j] = bossSegs[j + 1];
        bossSegCount--;
        if (hi == 0) bossFrontPx++;
        turnScore += SCORE_BOSS_SEG;
      }
    } else if (bossType == 1) {
      if (++bossWrongHits >= 3 && bossState != BS_RAGE) {
        bossState = BS_RAGE; bossRageBurst = bossParams()[BD_BURST];
        tBossAct = millis(); bossWrongHits = 0;
      }
    }
  }
  return (bossSegCount == 0);
}

void updateBoss() {
  uint32_t now = millis();
  const uint8_t* p = bossParams();

  switch (bossType) {
    case 1:  // ── Rampart ──────────────────────────────────────────────────
      if (bossState == BS_RAGE) {
        if (now - tBossAct >= 200) {
          tBossAct = now;
          uint8_t fc = bossSegCount ? bossSegs[0].color : COLOR_RED;
          uint8_t rc; do { rc = randPrimary(); } while (rc == fc);
          spawnBossProj(rc);
          if (--bossRageBurst == 0) bossState = BS_ADVANCE;
        }
      } else {
        accumulatorAdvanceBoss(p[BD_MOVE]);
        if (now - tBossAct >= (uint32_t)p[BD_FREQ] * 100) {
          tBossAct = now;
          uint8_t fc = bossSegCount ? bossSegs[0].color : COLOR_RED;
          uint8_t sc;
          if (random(100) < 20) sc = fc;
          else { do { sc = randPrimary(); } while (sc == fc); }
          spawnBossProj(sc);
        }
      }
      break;

    case 2:  // ── Sentinel ─────────────────────────────────────────────────
      if (bossState == BS_ADVANCE) {
        accumulatorAdvanceBoss(p[BD_MOVE]);
        if (bossSentSec < 3) {
          int mk = (int)((uint32_t)GAME_LEDS * SENTINEL_MK[bossSentSec] / 100);
          if (bossFrontPx <= mk) {
            bossFrontPx = mk; bossState = BS_CHARGE; tBossAct = now;
            bossSentColor = randPrimary();
            uint8_t surv = 0;
            for (uint8_t i = 0; i < bossSegCount; i++) if (bossSegs[i].active) surv++;
            bossSentTarget = 10 + surv * 3;
          }
        }
      } else if (bossState == BS_CHARGE) {
        if (now - tBossAct >= (uint32_t)p[BD_FREQ] * 100) {
          bossState = BS_BARRAGE; bossSentShots = 0; tBossAct = now;
          for (uint8_t i = 0; i < bossSegCount; i++) bossSegs[i].color = bossSentColor;
        }
      } else if (bossState == BS_BARRAGE) {
        if (now - tBossAct >= 150) {
          tBossAct = now; spawnBossProj(bossSentColor);
          if (++bossSentShots >= bossSentTarget) {
            bossState = BS_VULNERABLE;
            for (uint8_t i = 0; i < bossSegCount; i++) bossSegs[i].active = true;
            tBossAct = now;
          }
        }
      } else if (bossState == BS_VULNERABLE) {
        if (now - tBossAct >= 5000) { bossSentSec++; bossState = BS_ADVANCE; }
      }
      break;

    case 3:  // ── Prism ─────────────────────────────────────────────────────
      if (bossState == BS_ADVANCE) {
        accumulatorAdvanceBoss(p[BD_MOVE]);
        if (bossPrismPh < 2) {
          int mk = (int)((uint32_t)GAME_LEDS * PRISM_MK[bossPrismPh] / 100);
          if (bossFrontPx <= mk) { bossState = BS_PHASE_CHANGE; tBossAct = now; }
        }
        if (now - tBossAct >= (uint32_t)p[BD_FREQ] * 100) {
          tBossAct = now; spawnBossProj(randPrimary());
        }
      } else if (bossState == BS_PHASE_CHANGE) {
        if (now - tBossAct >= 4000) {
          bossPrismPh++;
          for (uint8_t i = 0; i < bossSegCount; i++) bossSegs[i].color = randMixed();
          bossState = BS_BURST; bossBurstFired = 0; tBossAct = now;
        }
      } else if (bossState == BS_BURST) {
        if (now - tBossAct >= 200) {
          tBossAct = now;
          spawnBossProj(random(100) < 50 ? randPrimary() : randMixed());
          if (++bossBurstFired >= p[BD_BURST]) { bossState = BS_COOLDOWN; tBossAct = now; }
        }
      } else if (bossState == BS_COOLDOWN) {
        if (now - tBossAct >= 2000) { bossState = BS_ADVANCE; tBossAct = now; }
      }
      break;
  }
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  14. Effects                                                              ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

void tickEffects() {
  if (dispDimUntil > 0 && millis() >= dispDimUntil) {
    disp.setBrightness(DISP_NORMAL); dispDimUntil = 0;
  }
  if (starFlashStep > 0 && millis() - tStarFlash >= STAR_FLASH_MS) {
    tStarFlash = millis();
    bool on = (starFlashStep % 2 == 1);
    btnLEDs(on, on, on);
    if (++starFlashStep > 4) { starFlashStep = 0; btnLEDs(true, true, true); }
  }
  int16_t total = plrScores[curPlayer] + turnScore;
  if (total != prevDispScore) {
    disp.showNumber((int)total); prevDispScore = total;
    if (dispDimUntil > 0) disp.setBrightness(0);
  }
}

uint16_t calcMaxScore(uint8_t lv) {
  uint8_t li = min(lv, (uint8_t)(NUM_LEVELS - 1));
  uint8_t  w = LEVELS[li][LVL_WAVES];   // 0 = boss-only, no wave score
  uint16_t perWave =
      LEVELS[li][LVL_ASTEROID] * SCORE_ASTEROID
    + LEVELS[li][LVL_PLANET]   * SCORE_PLANET
    + LEVELS[li][LVL_STAR]     * SCORE_STAR
    + (LEVELS[li][LVL_METEOR] + LEVELS[li][LVL_CMETEOR]) * METEOR_WIDTH * SCORE_METEOR;
  return perWave * w;
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  15. Round-robin flow                                                     ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

bool advanceToNextPlayer() {
  roundTurnIdx++;
  while (roundTurnIdx < plrCount && !plrActive[plrOrder[roundTurnIdx]]) roundTurnIdx++;
  return (roundTurnIdx < plrCount);
}

void advanceToNextLevel() {
  roundLevel++;
  if (roundLevel > stopLvl) return;
  roundTurnIdx = 0;
  while (roundTurnIdx < plrCount && !plrActive[plrOrder[roundTurnIdx]]) roundTurnIdx++;
}

void buildPlayerOrder() {
  plrCount = 0;
  for (uint8_t i = 0; i < NUM_PLAYERS_MAX; i++)
    if (plrMask & (1 << i)) plrOrder[plrCount++] = i;
  for (uint8_t i = 0; i < NUM_PLAYERS_MAX; i++) {
    plrScores[i] = 0;
    plrActive[i] = (plrMask & (1 << i)) != 0;
  }
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  16. State handlers                                                       ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

// ── Player selection ─────────────────────────────────────────────────────────

void enterSelPlayers() {
  enterState(ST_SEL_PLAYERS);
  roundLevel = 0;
  plrMask = eeReadPlr(); if (!plrMask) plrMask = 0x01;
  menuCursor = 0;
  disp.setBrightness((plrMask & 1) ? DISP_BRIGHT : DISP_NORMAL);
  disp.showString(PLAYER_NAMES[0]);
  btnLEDs(true, true, true);
  fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs(); FastLED.show();
  #if TEST
  Serial.println(F("ST: Select Players"));
  #endif
}

void runSelPlayers() {
  bool ch = false;
  if (pressR()) { menuCursor = (menuCursor + 1) % NUM_PLAYERS_MAX; ch = true; }
  if (pressB()) { menuCursor = (menuCursor + NUM_PLAYERS_MAX - 1) % NUM_PLAYERS_MAX; ch = true; }
  if (pressG()) { plrMask ^= (1 << menuCursor); if (!plrMask) plrMask = (1 << menuCursor); ch = true; }
  if (ch) {
    disp.setBrightness((plrMask & (1 << menuCursor)) ? DISP_BRIGHT : DISP_NORMAL);
    disp.showString(PLAYER_NAMES[menuCursor]);
  }
  if (pressRst()) { eeWritePlr(plrMask); enterSelStartLevel(); }
}

// ── Start level selection ────────────────────────────────────────────────────

void enterSelStartLevel() {
  enterState(ST_SEL_START_LVL);
  startLvl = eeReadStartLvl();
  stopLvl  = eeReadStopLvl();
  if (stopLvl < startLvl) stopLvl = startLvl;
  disp.setBrightness(DISP_NORMAL);
  showStartLvlOnDisp(startLvl);
  #if TEST
  Serial.println(F("ST: Select Start Level"));
  #endif
}

void runSelStartLevel() {
  bool ch = false;
  if (pressR() && startLvl < NUM_LEVELS - 1) { startLvl++; if (stopLvl < startLvl) stopLvl = startLvl; ch = true; }
  if (pressB() && startLvl > 0)              { startLvl--; ch = true; }
  if (ch) showStartLvlOnDisp(startLvl);
  if (pressRst() || pressG()) { eeWriteStartLvl(startLvl); enterSelStopLevel(); }
}

// ── Stop level selection ─────────────────────────────────────────────────────

void enterSelStopLevel() {
  enterState(ST_SEL_STOP_LVL);
  disp.setBrightness(DISP_NORMAL);
  showStopLvlOnDisp(stopLvl);
  #if TEST
  Serial.println(F("ST: Select Stop Level"));
  #endif
}

void runSelStopLevel() {
  bool ch = false;
  if (pressR() && stopLvl < NUM_LEVELS - 1) { stopLvl++; ch = true; }
  if (pressB() && stopLvl > startLvl)       { stopLvl--; ch = true; }
  if (ch) showStopLvlOnDisp(stopLvl);
  if (pressRst() || pressG()) {
    eeWriteStopLvl(stopLvl);
    buildPlayerOrder();
    roundLevel = startLvl;
    roundTurnIdx = 0;
    enterAnnounce();
  }
}

// ── Announce (countdown ribbon + name blink + button flicker) ────────────────

void enterAnnounce() {
  uint8_t active = countActivePlayers();
  if (roundLevel > stopLvl || active == 0) { enterStandings(); return; }
  if (!isSolo() && active == 1)            { enterStandings(); return; }
  while (roundTurnIdx < plrCount && !plrActive[plrOrder[roundTurnIdx]]) roundTurnIdx++;
  if (roundTurnIdx >= plrCount) { advanceToNextLevel(); enterAnnounce(); return; }

  curPlayer = plrOrder[roundTurnIdx];
  enterState(ST_ANNOUNCE);
  disp.setBrightness(DISP_NORMAL);
  disp.showString(PLAYER_NAMES[curPlayer]);
  fill_solid(leds, NUM_LEDS, CRGB::Black);
  for (uint8_t i = 0; i < GAME_LEDS; i++) setPixel(i, CRGB::White);
  renderLevelLEDs(); FastLED.show();
  #if TEST
  Serial.print(F("Level ")); Serial.print(roundLevel + 1);
  Serial.print(F(" - Player: ")); Serial.println(PLAYER_NAMES[curPlayer]);
  #endif
}

void runAnnounce() {
  uint32_t elapsed = millis() - tState;
  if (elapsed >= PLAYER_SHOW_MS) { enterWave(); return; }

  uint8_t remaining = GAME_LEDS - (uint32_t)elapsed * GAME_LEDS / PLAYER_SHOW_MS;
  fill_solid(leds, NUM_LEDS, CRGB::Black);
  for (uint8_t i = 0; i < remaining && i < GAME_LEDS; i++) setPixel(i, CRGB::White);
  renderLevelLEDs(); FastLED.show();

  bool nameVisible = ((elapsed / NAME_BLINK_MS) % 2 == 0);
  if (nameVisible) { disp.setBrightness(DISP_NORMAL); disp.showString(PLAYER_NAMES[curPlayer]); }
  else disp.clear();

  uint8_t phase = (elapsed / BTN_FLICKER_MS) % 3;
  btnLEDs(phase == 0, phase == 1, phase == 2);
}

// ── Wave phase ────────────────────────────────────────────────────────────────

void spawnNextWave() {
  uint8_t li = min(roundLevel, (uint8_t)(NUM_LEVELS - 1));
  uint8_t c = buildWave(roundLevel);
  for (uint8_t i = 0; i < c && i < MAX_ENEMIES; i++) enemies[i] = waveBuf[i];
  enemyCount   = min(c, (uint8_t)MAX_ENEMIES);
  enemyFront   = GAME_LEDS - 1 + (waveNum > 0 ? LEVELS[li][LVL_GAP] : 0);
  tInvMove     = millis();
  btnReleased  = true;
  comboWaiting = false;
  #if TEST
  Serial.print(F("  Wave ")); Serial.print(waveNum + 1);
  Serial.print(F("/")); Serial.println(wavesTotal);
  #endif
}

void enterWave() {
  enterState(ST_WAVE);
  turnScore = 0; prevDispScore = -9999;
  dispDimUntil = 0; starFlashStep = 0;
  waveDistDone = false;   // [improvement 4]

  uint8_t li = min(roundLevel, (uint8_t)(NUM_LEVELS - 1));
  // bossType is 0-3; clamp so a typo never produces an unhandled switch case
  bossType   = min((uint8_t)LEVELS[li][LVL_BOSS_TYPE], (uint8_t)3);
  // bossDiff is 1-3; 0 is invalid — clamp to 1 so a typo never breaks a boss level
  bossDiff   = (bossType > 0)
               ? max((uint8_t)LEVELS[li][LVL_BOSS_DIFF], (uint8_t)1)
               : 0;
  wavesTotal = LEVELS[li][LVL_WAVES];
  waveNum    = 0;

  // waves == 0 means boss-only level: skip wave phase entirely
  if (wavesTotal == 0) {
    waveDistDone = true;   // no wave distance bonus
    if (bossType > 0 && bossDiff > 0) { enterBoss(); return; }
    enterLevelDone(); return;
  }

  for (uint8_t b = 0; b < MAX_BULLETS; b++) bullets[b].active = false;
  tBulMove = millis();
  spawnNextWave();

  btnLEDs(true, true, true);
  disp.setBrightness(DISP_NORMAL);
  disp.showNumber((int)plrScores[curPlayer]);
}

void runWave() {
  uint32_t now = millis();
  if (now - tInvMove >= waveSpeedMs()) { tInvMove = now; enemyFront--; }
  if (now - tBulMove >= BULLET_SPEED_MS) { tBulMove = now; moveBullets(); }
  if (waveCollisions()) { enterEliminated(); return; }

  if (enemyCount == 0) {
    // [improvement 4] accumulate distance bonus here per wave, flag it so
    // enterLevelDone() knows not to add it again from lastFront.
    turnScore   += max(enemyFront, (int16_t)0) / SCORE_DIST_DIV;
    waveDistDone = true;

    if (waveNum < wavesTotal - 1) {
      waveNum++;
      for (uint8_t b = 0; b < MAX_BULLETS; b++) bullets[b].active = false;
      spawnNextWave();
      return;
    }
    if (bossType > 0 && bossDiff > 0) { enterBoss(); return; }
    enterLevelDone();
    return;
  }

  tickEffects();
  uint8_t c = pollGameInput();
  if (c != COLOR_NONE) fireBullet(c);
  renderWave();
}

// ── Boss phase ────────────────────────────────────────────────────────────────

void enterBoss() {
  enterState(ST_BOSS); initBoss();
  for (uint8_t b = 0; b < MAX_BULLETS; b++) bullets[b].active = false;
  btnReleased = true; comboWaiting = false;
  tBulMove = tBossProjMove = millis();
}

void runBoss() {
  uint32_t now = millis();
  if (now - tBulMove >= BULLET_SPEED_MS) { tBulMove = now; moveBullets(); }
  updateBoss(); moveBossProjs();
  if (bossBasReached)  { enterEliminated(); return; }   // [improvement 2]
  if (bossFrontPx < 0) { enterEliminated(); return; }
  if (bossCollisions()) {
    bossType = 0; bossDiff = 0;
    lastFront = bossFrontPx;   // [improvement 3] int16_t, no cast needed
    enterLevelDone(); return;
  }
  tickEffects();
  uint8_t c = pollGameInput();
  if (c != COLOR_NONE) fireBullet(c);
  renderBoss();
}

// ── Level done ────────────────────────────────────────────────────────────────

void enterLevelDone() {
  enterState(ST_LEVEL_DONE);
  // [improvement 4] only add distance bonus if we came from a boss kill;
  // wave-phase bonus was already accumulated per-wave in runWave().
  if (!waveDistDone)
    turnScore += max(lastFront, (int16_t)0) / SCORE_DIST_DIV;
  if (turnScore < 0) turnScore = 0;   // negative level score counts as zero
  plrScores[curPlayer] += turnScore;

  uint16_t hs = eeReadHS(roundLevel, curPlayer);
  if ((uint16_t)turnScore > hs) eeWriteHS(roundLevel, curPlayer, (uint16_t)turnScore);

  disp.setBrightness(DISP_NORMAL);
  disp.showNumber((int)plrScores[curPlayer]);
  fill_solid(leds, NUM_LEDS, CRGB::Green);
  renderLevelLEDs(); FastLED.show();
  btnLEDs(false, false, false);
}

void runLevelDone() {
  uint32_t elapsed = millis() - tState;
  if (elapsed < LEVEL_DONE_MS) {
    bool on = ((elapsed / FLASH_HALF_MS) % 2 == 0);
    fill_solid(leds, NUM_LEDS, on ? CRGB::Green : CRGB::Black);
    renderLevelLEDs(); FastLED.show();
  } else {
    if (advanceToNextPlayer()) enterAnnounce();
    else { advanceToNextLevel(); enterAnnounce(); }
  }
}

// ── Eliminated ───────────────────────────────────────────────────────────────

void enterEliminated() {
  enterState(ST_ELIMINATED);
  if (turnScore < 0) turnScore = 0;   // negative level score counts as zero
  plrScores[curPlayer] += turnScore;
  elimHsDrawn = false;

  uint16_t hs = eeReadHS(roundLevel, curPlayer);
  if ((uint16_t)turnScore > hs) eeWriteHS(roundLevel, curPlayer, (uint16_t)turnScore);

  disp.setBrightness(DISP_NORMAL);
  btnLEDs(false, false, false);
  #if TEST
  Serial.print(F("Eliminated: ")); Serial.print(PLAYER_NAMES[curPlayer]);
  Serial.print(F("  level score: ")); Serial.print(turnScore);
  Serial.print(F("  total: ")); Serial.println(plrScores[curPlayer]);
  #endif
}

void runEliminated() {
  uint32_t elapsed  = millis() - tState;
  uint32_t flashDur = FLASH_COUNT * 2 * FLASH_HALF_MS;

  if (elapsed < flashDur) {
    fill_solid(leds, NUM_LEDS, ((elapsed / FLASH_HALF_MS) % 2 == 0) ? CRGB::Red : CRGB::Black);
    renderLevelLEDs(); FastLED.show();
  } else if (elapsed < flashDur + ELIM_PAUSE_MS) {
    fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs(); FastLED.show();
    disp.showString(TEXT_OVER);
  } else if (elapsed < flashDur + ELIM_PAUSE_MS + HS_SHOW_MS) {
    if (!elimHsDrawn) {
      fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs();
      uint16_t hs = eeReadHS(roundLevel, curPlayer);
      uint16_t sc = (uint16_t)max(turnScore, (int16_t)0);
      // Scale to the larger of theoretical max, high score, or current score
      uint16_t mx = max(calcMaxScore(roundLevel), max(hs, max(sc, (uint16_t)1)));
      uint8_t hsLen = map(hs, 0, mx, 0, GAME_LEDS);
      uint8_t scLen = map(sc, 0, mx, 0, GAME_LEDS);
      if (sc <= hs) {
        // Below high score: white = my score, red = gap to high score
        for (uint8_t i = 0; i < scLen; i++) setPixel(i, CRGB::White);
        for (uint8_t i = scLen; i < hsLen; i++) setPixel(i, CRGB::Red);
      } else {
        // New high score: white = old high score, green = improvement
        for (uint8_t i = 0; i < hsLen; i++) setPixel(i, CRGB::White);
        for (uint8_t i = hsLen; i < scLen; i++) setPixel(i, CRGB::Green);
      }
      FastLED.show();
      disp.showNumber((int)turnScore);
      elimHsDrawn = true;
    }
  } else {
    if (isSolo()) { enterSelPlayers(); return; }   // solo eliminated: back to menu, no green bar
    if (advanceToNextPlayer()) enterAnnounce();
    else { advanceToNextLevel(); enterAnnounce(); }
  }
}

// ── Standings ────────────────────────────────────────────────────────────────

void enterStandings() {
  enterState(ST_STANDINGS);
  uint8_t n = 0;
  for (uint8_t i = 0; i < plrCount; i++) standOrder[n++] = plrOrder[i];
  for (uint8_t i = 1; i < n; i++) {
    uint8_t k = standOrder[i]; int8_t j = i - 1;
    while (j >= 0 && plrScores[standOrder[j]] > plrScores[k]) {
      standOrder[j + 1] = standOrder[j]; j--;
    }
    standOrder[j + 1] = k;
  }
  prevStandIdx = 255; prevStandPhase = false;
  disp.setBrightness(DISP_NORMAL);
  eeWritePlr(plrMask); eeWriteStartLvl(startLvl); eeWriteStopLvl(stopLvl);
  #if TEST
  Serial.println(F("ST: Standings"));
  #endif
}

void runStandings() {
  uint32_t elapsed = millis() - tState;
  uint32_t slot    = STAND_NAME_MS + STAND_SCORE_MS;
  if (elapsed >= plrCount * slot) { enterWinner(); return; }
  uint8_t idx       = elapsed / slot;
  bool    namePhase = (elapsed % slot) < STAND_NAME_MS;
  if (idx == prevStandIdx && namePhase == prevStandPhase) return;
  prevStandIdx = idx; prevStandPhase = namePhase;

  uint8_t p = standOrder[idx];
  fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs();
  int16_t maxScore = 1;
  for (uint8_t i = 0; i < plrCount; i++)
    if (plrScores[plrOrder[i]] > maxScore) maxScore = plrScores[plrOrder[i]];
  uint8_t fillLen = map(constrain(plrScores[p], 0, maxScore), 0, maxScore, 0, GAME_LEDS);
  for (uint8_t i = 0; i < fillLen; i++) setPixel(i, CRGB::Green);
  FastLED.show();
  if (namePhase) disp.showString(PLAYER_NAMES[p]);
  else           disp.showNumber((int)plrScores[p]);
}

// ── Winner ───────────────────────────────────────────────────────────────────

void enterWinner() {
  enterState(ST_WINNER);
  disp.setBrightness(DISP_NORMAL);
  if (isSolo()) {
    disp.showNumber((int)plrScores[plrOrder[0]]);
    fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs();
    uint8_t fillLen = map(constrain(plrScores[plrOrder[0]], 0, SOLO_SCORE_MAX),
                          0, SOLO_SCORE_MAX, 0, GAME_LEDS);
    for (uint8_t i = 0; i < fillLen; i++) setPixel(i, CRGB::Green);
    FastLED.show();
  } else {
    disp.showString(PLAYER_NAMES[standOrder[plrCount - 1]]);
  }
  #if TEST
  if (!isSolo())
    { Serial.print(F("Winner: ")); Serial.println(PLAYER_NAMES[standOrder[plrCount - 1]]); }
  #endif
}

void runWinner() {
  uint32_t elapsed = millis() - tState;
  if (isSolo()) { if (elapsed >= WINNER_SHOW_MS) enterSelPlayers(); return; }
  if (elapsed < WINNER_SHOW_MS) return;
  uint32_t sweepElapsed = elapsed - WINNER_SHOW_MS;
  uint32_t sweepDur     = (uint32_t)RAINBOW_WAVES * RAINBOW_STEP_MS;
  if (sweepElapsed >= sweepDur) { enterSelPlayers(); return; }
  uint8_t wave = sweepElapsed / RAINBOW_STEP_MS;
  for (uint8_t i = 0; i < NUM_LEDS; i++)
    leds[i] = CHSV((i * 255 / NUM_LEDS + wave * 4) % 256, 255, 255);
  renderLevelLEDs(); FastLED.show();
}

// ╔═══════════════════════════════════════════════════════════════════════════╗
// ║  17. Setup and loop                                                       ║
// ╚═══════════════════════════════════════════════════════════════════════════╝

void setup() {
  randomSeed(analogRead(A0));
  FastLED.addLeds<WS2815, PIN_STRIP, RGB>(leds, NUM_LEDS);
  FastLED.setBrightness(STRIP_BRIGHT);
  FastLED.setMaxPowerInVoltsAndMilliamps(POWER_VOLTS, POWER_MA);
  disp.begin(); disp.setBrightness(DISP_NORMAL);
  pinMode(PIN_PUSH_R, INPUT_PULLUP);
  pinMode(PIN_PUSH_G, INPUT_PULLUP);
  pinMode(PIN_PUSH_B, INPUT_PULLUP);
  pinMode(PIN_PUSH,   INPUT_PULLUP);
  pinMode(PIN_LED_R, OUTPUT);
  pinMode(PIN_LED_G, OUTPUT);
  pinMode(PIN_LED_B, OUTPUT);
  #if TEST
    Serial.begin(BAUD);
    String year = String(__DATE__).substring(7);
    Serial.println(F("====================== Start ======================"));
    Serial.println(F("           Software: Wormgat Indringers"));
    Serial.print(  F("          Copyright: CC BY-NC-SA "));
    Serial.print(year); Serial.print(F(" ")); Serial.println(F(MAKER));
    Serial.println(F("   Software version: " VERSION));
    Serial.print(  F("           Compiled: "));
    Serial.print(F(__DATE__)); Serial.print(F(" - ")); Serial.println(F(__TIME__));
    Serial.println(F("           Hardware: Arduino 2560 Mini"));
  #endif
  eepromInit();
  fill_solid(leds, NUM_LEDS, CRGB::Black); renderLevelLEDs(); FastLED.show();
  disp.setScrolldelay(300);
  disp.showString(TEXT_NAME); delay(1000);
  disp.showString(VERSION);   delay(1000);
  enterSelPlayers();
}

void loop() {
  tickRst();                                      // [improvement 5] single reset state machine
  if (checkLongReset()) { enterSelPlayers(); return; }
  switch (state) {
    case ST_SEL_PLAYERS:   runSelPlayers();    break;
    case ST_SEL_START_LVL: runSelStartLevel(); break;
    case ST_SEL_STOP_LVL:  runSelStopLevel();  break;
    case ST_ANNOUNCE:      runAnnounce();      break;
    case ST_WAVE:          runWave();          break;
    case ST_BOSS:          runBoss();          break;
    case ST_LEVEL_DONE:    runLevelDone();     break;
    case ST_ELIMINATED:    runEliminated();    break;
    case ST_STANDINGS:     runStandings();     break;
    case ST_WINNER:        runWinner();        break;
  }
}
