/*ESP8266 Horizontal Shooter Game on 8x32 Matrix WS2812b
by mircemk, May 2025
*/
#include <FastLED.h>
// Matrix dimensions
#define MATRIX_WIDTH 32
#define MATRIX_HEIGHT 8
#define NUM_LEDS (MATRIX_WIDTH * MATRIX_HEIGHT)
// Pin definitions
#define LED_PIN     D6
#define BTN_UP      D2
#define BTN_DOWN    D3
#define BTN_FIRE    D4
#define BUZZER_PIN  D8
// Game parameters
#define PLAYER_COLOR CRGB::Green
#define MISSILE_COLOR CRGB::Yellow
#define ENEMY_COLOR CRGB::Red
#define ENEMY_WEAPON_COLOR CRGB::Magenta
#define ENEMY_MISSILE_COLOR CRGB::Magenta
#define PLAYER_SPEED 1
#define MISSILE_SPEED 2
#define ENEMY_SPEED 0.5f
#define ENEMY_FIRE_RATE 0.02
#define SCROLL_SPEED 80
#define START_TEXT "PRESS FIRE TO START"
#define GAMEOVER_TEXT "GAME OVER"
#define SCORE_TEXT "SCORE - "
// Sound definitions
#define SOUND_SHOOT_FREQ     2000
#define SOUND_SHOOT_DURATION 40
#define SOUND_ENEMY_HIT_FREQ1 800
#define SOUND_ENEMY_HIT_FREQ2 1200
#define SOUND_ENEMY_HIT_DURATION 20
#define SOUND_GAMEOVER_FREQ1 400
#define SOUND_GAMEOVER_FREQ2 300
#define SOUND_GAMEOVER_DURATION 100
#define SOUND_START_FREQ 1000
#define SOUND_START_DURATION 150
#define SOUND_SCORE_FREQ 1500
#define SOUND_SCORE_DURATION 50
#define SQUARE_WAVE_DUTY_CYCLE 50 // Percentage of time the pin is HIGH
// Sound management
unsigned long soundEndTime = 0;
bool soundActive = false;
void playTone(int frequency, int duration) {
  unsigned long period = 1000000L / frequency; // Period in microseconds
  unsigned long halfPeriod = period / 2;
  unsigned long startTime = micros();
  while (micros() - startTime < duration * 1000L) {
    digitalWrite(BUZZER_PIN, HIGH);
    delayMicroseconds(halfPeriod);
    digitalWrite(BUZZER_PIN, LOW);
    delayMicroseconds(halfPeriod);
  }
  noTone(BUZZER_PIN); // Ensure the buzzer is off after the tone
}
void updateSound() {
  if (soundActive && millis() > soundEndTime) {
    noTone(BUZZER_PIN);
    soundActive = false;
  }
}
void playEnemyDestroyedSound() {
  playTone(SOUND_ENEMY_HIT_FREQ1, SOUND_ENEMY_HIT_DURATION);
  delay(60);
  playTone(SOUND_ENEMY_HIT_FREQ2, SOUND_ENEMY_HIT_DURATION);
}
void playGameOverSound() {
  playTone(SOUND_GAMEOVER_FREQ1, SOUND_GAMEOVER_DURATION);
  delay(350);
  playTone(SOUND_GAMEOVER_FREQ2, SOUND_GAMEOVER_DURATION);
}
// Game state variables
bool upButtonPressed = false;
bool downButtonPressed = false;
unsigned long lastMoveTime = 0;
#define MOVE_COOLDOWN 100
CRGB leds[NUM_LEDS];
enum GameState {
  TITLE_SCREEN,
  PLAYING,
  GAME_OVER,
  SCORE_DISPLAY
};
GameState gameState = TITLE_SCREEN;
unsigned long gameOverStartTime = 0;
bool waitingForFireButton = false;
// Game objects
struct Player {
  int x = 0;
  int y = MATRIX_HEIGHT / 2;
  bool moveRequested = false;
} player;
struct Missile {
  float xPos;
  int x;
  int y;
  bool active = false;
};
#define MAX_MISSILES 3
Missile playerMissiles[MAX_MISSILES];
Missile enemyMissiles[5];
struct Enemy {
  float xPos;
  int x;
  int y;
  bool active = false;
  bool hasWeapon = true;
};
#define MAX_ENEMIES 3
Enemy enemies[MAX_ENEMIES];
int score = 0;
int lives = 3;
unsigned long lastEnemySpawn = 0;
#define ENEMY_SPAWN_RATE 1500
bool fireButtonPressed = false;
unsigned long lastFireTime = 0;
#define FIRE_COOLDOWN 300
// Font 5x4
const uint8_t font5x4[44][4] = {
  {31,20,20,31}, {31,21,21,10}, {14,17,17,10}, {31,17,17,14},
  {31,21,21,17}, {31,20,20,16}, {14,17,21,14}, {31,4,4,31},
  {17,31,17,17}, {2,1,1,30}, {31,4,10,17}, {31,1,1,1},
  {31,12,12,31}, {31,12,3,31}, {14,17,17,14}, {31,20,20,8},
  {14,17,19,14}, {31,20,22,9}, {8,21,21,2}, {16,16,31,16},
  {30,1,1,30}, {28,3,3,28}, {31,3,12,31}, {27,4,4,27},
  {24,4,3,28}, {19,21,25,17}, {0,0,0,0},
  {14,17,17,14}, {0,17,31,1}, {19,21,21,9},
  {17,21,21,14}, {28,4,4,31}, {30,21,21,18},
  {14,21,21,2}, {16,19,20,24}, {10,21,21,10},
  {8,21,21,14}, {0,4,0,0}, {0,0,0,0}
};
int getCharIndex(char c) {
  if (c >= 'A' && c <= 'Z') return c - 'A';
  if (c >= '0' && c <= '9') return c - '0' + 26; // Correct mapping for digits
  if (c == '-') return 36;
  if (c == ' ') return 37;
  return 37;
}
int XY(int x, int y) {
  int flippedX = (MATRIX_WIDTH - 1) - x;
  int flippedY = (MATRIX_HEIGHT - 1) - y;
  if (flippedX % 2 == 0) {
    return (flippedX * MATRIX_HEIGHT) + flippedY;
  } else {
    return (flippedX * MATRIX_HEIGHT) + (MATRIX_HEIGHT - 1 - flippedY);
  }
}
int XY_text(int x, int y) {
  x = (MATRIX_WIDTH - 1) - x;
  y = (MATRIX_HEIGHT - 1) - y;
  if (x % 2 == 0) {
    return (x * MATRIX_HEIGHT) + y;
  } else {
    return (x * MATRIX_HEIGHT) + (MATRIX_HEIGHT - 1 - y);
  }
}
void enemyDestructionAnimation(int enemyX, int enemyY) {
  // Light up the top and bottom positions around the enemy's red LED
  // Adjust the positions if necessary to match your LED layout
  leds[XY(enemyX, enemyY - 1)] = CRGB::Orange;
  leds[XY(enemyX, enemyY + 1)] = CRGB::Orange;
  FastLED.show();
  
  // Keep the animation for 100 milliseconds before clearing
  delay(100);
  
  // Clear the animated LEDs (set to black)
  leds[XY(enemyX, enemyY - 1)] = CRGB::Black;
  leds[XY(enemyX, enemyY + 1)] = CRGB::Black;
  FastLED.show();
}
void drawChar(int x, int y, char c, CRGB color) {
  int charIndex;
  if (c >= '0' && c <= '9') {
    charIndex = getCharIndex(c + 1); // Try adding 1 to the character value
  } else {
    charIndex = getCharIndex(c);
  }
  for (int col = 0; col < 4; col++) {
    if (x + col >= 0 && x + col < MATRIX_WIDTH) {
      uint8_t pattern = font5x4[charIndex][col];
      for (int row = 0; row < 5; row++) {
        if (pattern & (1 << (4 - row))) {
          if (y + row >= 0 && y + row < MATRIX_HEIGHT) {
            leds[XY_text(x + col, y + row)] = color;
          }
        }
      }
    }
  }
}
void scrollText(const char* text, CRGB color) {
  static int scrollX = MATRIX_WIDTH;
  static unsigned long lastScroll = 0;
  
  if (millis() - lastScroll > SCROLL_SPEED) {
    FastLED.clear();
    int textLen = strlen(text);
    for (int i = 0; i < textLen; i++) {
      drawChar(scrollX + (i * 5), 1, text[i], color);
    }
    FastLED.show();
    scrollX--;
    if (scrollX < -(textLen * 5)) {
      scrollX = MATRIX_WIDTH;
    }
    lastScroll = millis();
  }
}
void firePlayerMissile() {
  for (int i = 0; i < MAX_MISSILES; i++) {
    if (!playerMissiles[i].active) {
      playerMissiles[i].xPos = player.x + 1;
      playerMissiles[i].x = (int)playerMissiles[i].xPos;
      playerMissiles[i].y = player.y;
      playerMissiles[i].active = true;
      playTone(SOUND_SHOOT_FREQ, SOUND_SHOOT_DURATION);
      break;
    }
  }
}
void handleInput() {
  if (digitalRead(BTN_UP) == LOW) {
    if (!upButtonPressed && millis() - lastMoveTime > MOVE_COOLDOWN) {
      player.y = max(0, player.y - 1);
      upButtonPressed = true;
      lastMoveTime = millis();
    }
  } else {
    upButtonPressed = false;
  }
  
  if (digitalRead(BTN_DOWN) == LOW) {
    if (!downButtonPressed && millis() - lastMoveTime > MOVE_COOLDOWN) {
      player.y = min(MATRIX_HEIGHT-1, player.y + 1);
      downButtonPressed = true;
      lastMoveTime = millis();
    }
  } else {
    downButtonPressed = false;
  }
  
  if (digitalRead(BTN_FIRE) == LOW) {
    if (!fireButtonPressed && millis() - lastFireTime > FIRE_COOLDOWN) {
      firePlayerMissile();
      fireButtonPressed = true;
      lastFireTime = millis();
    }
  } else {
    fireButtonPressed = false;
  }
}
void updateGame() {
  // Update player missiles
  for (int i = 0; i < MAX_MISSILES; i++) {
    if (playerMissiles[i].active) {
      playerMissiles[i].xPos += MISSILE_SPEED;
      playerMissiles[i].x = (int)playerMissiles[i].xPos;
      if (playerMissiles[i].x >= MATRIX_WIDTH) {
        playerMissiles[i].active = false;
      }
    }
  }
  // Spawn enemies
  if (millis() - lastEnemySpawn > ENEMY_SPAWN_RATE) {
    lastEnemySpawn = millis();
    for (int i = 0; i < MAX_ENEMIES; i++) {
      if (!enemies[i].active) {
        enemies[i].xPos = MATRIX_WIDTH - 1;
        enemies[i].x = (int)enemies[i].xPos;
        enemies[i].y = random(0, MATRIX_HEIGHT - 1);
        enemies[i].active = true;
        enemies[i].hasWeapon = true;
        break;
      }
    }
  }
  // Update enemies
  for (int i = 0; i < MAX_ENEMIES; i++) {
    if (enemies[i].active) {
      enemies[i].xPos -= ENEMY_SPEED;
      enemies[i].x = (int)enemies[i].xPos;
      
      // Enemy firing
      if (enemies[i].hasWeapon && random(100) < (ENEMY_FIRE_RATE * 100)) {
        for (int j = 0; j < 5; j++) {
          if (!enemyMissiles[j].active) {
            enemyMissiles[j].xPos = enemies[i].xPos - 1;
            enemyMissiles[j].x = (int)enemyMissiles[j].xPos;
            enemyMissiles[j].y = enemies[i].y;
            enemyMissiles[j].active = true;
            break;
          }
        }
      }
      if (enemies[i].xPos < 0) enemies[i].active = false;
    }
  }
  // Update enemy missiles
  for (int i = 0; i < 5; i++) {
    if (enemyMissiles[i].active) {
      enemyMissiles[i].xPos -= ENEMY_SPEED;
      enemyMissiles[i].x = (int)enemyMissiles[i].xPos;
      if (enemyMissiles[i].xPos < 0) enemyMissiles[i].active = false;
    }
  }
  checkCollisions();
}
void checkCollisions() {
  // Player missiles vs enemies
  for (int m = 0; m < MAX_MISSILES; m++) {
    if (playerMissiles[m].active) {
      for (int e = 0; e < MAX_ENEMIES; e++) {
        if (enemies[e].active) {
 if ((playerMissiles[m].x == enemies[e].x && playerMissiles[m].y == enemies[e].y) ||
    (playerMissiles[m].x == enemies[e].x - 1 && playerMissiles[m].y == enemies[e].y)) {
  playerMissiles[m].active = false;
  // Save enemy position before deactivating (for animation)
  int enemyX = enemies[e].x;
  int enemyY = enemies[e].y;
  enemies[e].active = false;
  score += 10;
  playEnemyDestroyedSound();
  // Trigger the enemy destruction animation
  enemyDestructionAnimation(enemyX, enemyY);
          }
        }
      }
    }
  }
  // Enemy missiles vs player
  for (int i = 0; i < 5; i++) {
    if (enemyMissiles[i].active && enemyMissiles[i].x == player.x && enemyMissiles[i].y == player.y) {
      enemyMissiles[i].active = false;
      if (--lives <= 0) gameOver();
    }
  }
  // Enemies vs player
  for (int i = 0; i < MAX_ENEMIES; i++) {
    if (enemies[i].active && enemies[i].x == player.x && enemies[i].y == player.y) {
      enemies[i].active = false;
      if (--lives <= 0) gameOver();
    }
  }
}
void render() {
  FastLED.clear();
  // Draw player
  leds[XY(player.x, player.y)] = PLAYER_COLOR;
  // Draw player missiles
  for (int i = 0; i < MAX_MISSILES; i++) {
    if (playerMissiles[i].active) {
      leds[XY(playerMissiles[i].x, playerMissiles[i].y)] = MISSILE_COLOR;
    }
  }
  // Draw enemies
  for (int i = 0; i < MAX_ENEMIES; i++) {
    if (enemies[i].active) {
      leds[XY(enemies[i].x, enemies[i].y)] = ENEMY_COLOR;
      if (enemies[i].hasWeapon && enemies[i].x > 0) {
        leds[XY(enemies[i].x - 1, enemies[i].y)] = ENEMY_WEAPON_COLOR;
      }
    }
  }
  // Draw enemy missiles
  for (int i = 0; i < 5; i++) {
    if (enemyMissiles[i].active) {
      leds[XY(enemyMissiles[i].x, enemyMissiles[i].y)] = ENEMY_MISSILE_COLOR;
    }
  }
  // Draw score LEDs
  int scoreLeds = min(score / 10, MATRIX_WIDTH);
  for (int i = 0; i < scoreLeds; i++) {
    leds[XY(MATRIX_WIDTH - 1 - i, MATRIX_HEIGHT - 1)] = CRGB::Green;
  }
}
void gameOver() {
  // Flash red 3 times
  for (int i = 0; i < 3; i++) {
    fill_solid(leds, NUM_LEDS, CRGB::Red);
    FastLED.show();
    delay(300);
    FastLED.clear();
    FastLED.show();
    delay(300);
  }
  playGameOverSound(); // play sound after flashes
  gameState = GAME_OVER;
}
void loop() {
  static unsigned long lastFrame = millis();
  if (millis() - lastFrame < 33) {
    delay(1);
    return;
  }
  lastFrame = millis();
  updateSound(); // keep sound system updated!
  switch (gameState) {
    case TITLE_SCREEN:
      scrollText(START_TEXT, CRGB::Green);
      if (digitalRead(BTN_FIRE) == LOW) {
        playTone(SOUND_START_FREQ, SOUND_START_DURATION);
        gameState = PLAYING;
        resetGame();
        delay(200);
      }
      break;
    case PLAYING:
      handleInput();
      updateGame();
      render();
      FastLED.show();
      break;
    case GAME_OVER:
      if (gameOverStartTime == 0) {
        gameOverStartTime = millis();
        waitingForFireButton = false;
      }
      scrollText(GAMEOVER_TEXT, CRGB::Red);
      if (!waitingForFireButton && millis() - gameOverStartTime >= 3000) {
        waitingForFireButton = true;
      }
      if (waitingForFireButton && digitalRead(BTN_FIRE) == LOW) {
        gameState = SCORE_DISPLAY;
        gameOverStartTime = 0;
        waitingForFireButton = false;
        delay(200);
      }
      break;
case SCORE_DISPLAY:
      static bool scoreInitialized = false;
      static unsigned long lastScrollUpdate = 0; // Control scroll update rate
      if (!scoreInitialized) {
        FastLED.clear();
        scoreInitialized = true;
      }
      if (millis() - lastScrollUpdate > 75) { // Update every 75 milliseconds
        char scoreText[30] = "SCORE ";
        char scoreValue[6]; // Enough space for a 5-digit score + null terminator
        itoa(score, scoreValue, 10); // Convert score to string (base 10)
        strcat(scoreText, scoreValue); // Append score value to "SCORE "
        FastLED.clear();
        scrollText(scoreText, CRGB::Blue);
        leds[XY(MATRIX_WIDTH-1, MATRIX_HEIGHT-1)] = CRGB::Black;
        FastLED.show();
        lastScrollUpdate = millis();
      }
      if (digitalRead(BTN_FIRE) == LOW) {
        scoreInitialized = false;
        gameState = TITLE_SCREEN;
        delay(200);
      }
      break;
  }
  ESP.wdtFeed();
}
void setup() {
  Serial.begin(115200);
  FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
  FastLED.setBrightness(30);
  
  pinMode(BTN_UP, INPUT_PULLUP);
  pinMode(BTN_DOWN, INPUT_PULLUP);
  pinMode(BTN_FIRE, INPUT_PULLUP);
  pinMode(BUZZER_PIN, OUTPUT);
  resetGame();
}
void resetGame() {
  FastLED.clear();
  for (int i = 0; i < MAX_MISSILES; i++) playerMissiles[i].active = false;
  for (int i = 0; i < 5; i++) enemyMissiles[i].active = false;
  for (int i = 0; i < MAX_ENEMIES; i++) enemies[i].active = false;
  player.x = 0;
  player.y = MATRIX_HEIGHT / 2;
  score = 0;
  lives = 3;
  lastEnemySpawn = millis();
  player.moveRequested = false;
  gameOverStartTime = 0;
  waitingForFireButton = false;
}
Comments