본문 바로가기
# Programming/- Embedded Project

[STM32(ARM) Project] Pacman 게임 만들기(ADC, 조이스틱, I2C, LCD, PWM, Timer 등)

by Graffitio 2023. 9. 14.
[STM32(ARM) Project] Pacman 게임 만들기(ADC, 조이스틱, I2C, LCD, PWM, Timer 등)
728x90
반응형
[Mission]

 

Pacman

📌 Specification

      <개인 프로젝트>

     ✅ 제작 기간 : 2023.09.06~2023.09.08 (3 Days)

     ✅ Board : STM32 Nucleo - F411RE

     ✅ Tool : STM32CubeIDE

 

GitHub Link

 

GitHub - Graffitio/Project_Pacman

Contribute to Graffitio/Project_Pacman development by creating an account on GitHub.

github.com

 


 

📌 Mission

      : STM32 board와 각종 기능들을 활용하여 Pacman 게임을 구현해보자. 

      M1. 조이스틱을 이용하여 pacman의 움직임을 제어한다.

      M2. 통신 방식(UART, I2C 등) 중 하나를 선택하여 LCD로 게임을 출력한다.

      M3. Buzzer를 사용하여 게임 중 발생하는 Sound를 구현한다.

      M4. Enemy가 랜덤으로 이동하도록 설정

 


 

[Result]

 

📌 Operation

 

Game Start

 

Game Start(소리 포함)

 

Level Up

Level Up

Level up(소리 포함)

 

Win

Game Win

Game Win(소리 포함)

 

Lose

Game Lose

Game Lose(소리 포함)

 


 

[Setting]

 

📌 Pin Configuration

I2C LCD
SCL PB6
SDA PB7
VCC 5V
GND GND
Joystick
GND GND
VCC 5V
VRX PA0
VRY PA1
SW PC1
Buzzer
+ PA6
- GND

Clock Configuration

 


 

📌 Joystick Setting

 

조이스틱 내부 구성도

STM32 보드는 최대 12bit의 Resolution(해상도)의 ADC를 지원한다.

조이스틱에는 x축, y축 총 2개의 포텐셜미터가 장착되어 있고

조이스틱을 어느 쪽으로 돌리느냐(CW/CCW)에 따라서 0~4095 사이의

값으로 전압 레벨을 읽어올 수 있다.

 

 

GPIO Setting

 

 

Joystick의 Switch를 GPIO Input, 내부 Pull-up Setting

 

 

ADC Setting

 

 

    1. Mode : IN0, IN1 사용

    2. Resolution(해상도, 분해능) : 12 bit

    3. Scan Conversion Mode : Enable

    4. Continious Conversion Mode : Enable

    5. EOC : single conversion

    6. Number of Conversion : 2

    7. Conversion Source : Regular Conversion

    8. Rank 1 : Channel 0

    9. Rank 2 : Channel 1

    10. Sampling Time : 84 cycle (해상도가 12bit이므로 해상도보다 크게 설정)

    11. DMA - Circular mode 

 

조이스틱의 x, y 값은 DMA 방식으로 읽어올 것이므로 DMA 셋팅까지 함께 해준다.

 

DMA에 대한 설명 참조

 

[Harman 세미콘 아카데미] 43일차 - ARM & RTOS 활용(Timer, EXTI, DMA)

[Timer] Review 1. clock 발생 주기 계산법 Timer Interrupt 활용 /* USER CODE BEGIN PV */ int xVal=0, yVal=0, zVal=0; // 조이스틱 및 버튼 상태 변수 int cnt = 0; // ADC 변환 카운트 변수 int dir = 0; // 방향 변수 // 방향 문자

rangvest.tistory.com

 


 

📌 Timer Setting

 

Enemy의 움직임을 위한 Timer

 

주기 1sec Timer 생성

    초기 셋팅은 1Hz로 설정해주고, 게임 진행 Level에 따라 PSC를 조절하여 Enemy의 움직임을 빠르게 변화시켜줄 것이다.

External clock 사용

 


 

📌 Buzzer Setting

 

Buzzer

 

우리는 둘 중에서 수동형 부저를 사용할 것이다.

 

PWM Setting

 

    TIM3는 Passive Buzzer를 제어하기 위해 PWM mode로 Setting했다.

 


 

📌 I2C Setting

 

PCF8574

우리가 사용할 LCD는 I2C 통신이 가능하도록 I2C Interface adapter가 장착되어 있다.

일반 1602 LCD를 보면, 16개의 핀이 달려 있는데 이 어댑터가 하드웨어적으로 좀 더 간편하게 통신할 수 있다.

 

I2C 통신을 사용하기 위해서는 I2C 관련 함수들을 만들어줘야 하는데,

해당 I2C 라이브러리는 아래의 유튜버가 공유한 라이브러리를 merge하여 사용할 것이다.

 

 

LCD 모듈에는 CGROM이라는 저장 공간이 존재하는데, 이 저장소에는 기본적으로 사용할 수 있는 문자들이 저장되어 있다.

한 가지 단점은 일본에서 만든 제품이기 때문에 한국어 지원이 안 된다는 점이다.

 

하지만 방법이 없지는 않다.

CGRAM이라는 저장 공간도 존재하는데, 이 저장 공간에 5x8 도트의 최대 8개의 커스텀 문자를 저장하여 사용할 수 있고

이 방법을 활용하여 아래와 같이 Pacman과 Enemy의 도트를 직접 찍어서 사용할 것이다.

 

 

도트 찍는 사이트

 

LCD Custom Character Generator

Clear      Invert Link

maxpromer.github.io

 

통신 모드는 I2C mode로 설정하고

Parameter는 기본 설정으로,

Pin은 꼭 풀업저항으로 설정하여, 기본적으로 High 상테를 유지시켜줘야 한다.

 


 

[Code]

 

📌 Variable Declaration

 

프로젝트에 필요한 변수들을 구조체로 정의하여 선언하였고

enum type으로 구조체 변수를 선언하여 좀 더 수월하게 사용할 수 있도록 구성하였다.

 

/* USER CODE BEGIN PV */
typedef enum{
	ING,
	WIN,
	OVER
}Game_status;
Game_status game_status=ING;

typedef enum{
	LEVEL1=1,
	LEVEL2,
	LEVEL3
}Level;
Level level=LEVEL1;

typedef enum{
	UP,
	DOWN,
	RIGHT,
	LEFT,
	NONE
}Direction;

typedef struct{
	int row;
	int col;
	int image_num;
	int past_position[2][16];
	uint8_t food;
}Character;

typedef struct{
	int row;
	int col;
	int image_num;
	uint8_t clock_before;
}Enemy;

uint32_t dir[2];

uint8_t clk_pulse;
/* USER CODE END PV */

 


 

📌 Initial Setting

 

게임 시작 전까지의 코드 구성이다.

위에서 도트로 찍은 캐릭터들을 DDRAM에 저장한뒤, 게임이 시작된다.

 

 

 /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_DMA_Init();
  MX_USART2_UART_Init();
  MX_ADC1_Init();
  MX_TIM2_Init();
  MX_TIM3_Init();
  MX_I2C1_Init();
  /* USER CODE BEGIN 2 */
  HAL_ADC_Start_DMA(&hadc1, dir, 2);

  // LCD 초기화 함수 호출
  lcd_init();

  // Pacman 이미지 데이터
  char pac1[] = {0x07,  0x0F,  0x1C,  0x18,  0x18,  0x1C,  0x0F,  0x07}; // 우 벌
  char pac2[] = {0x00,  0x0F,  0x1F,  0x18,  0x1C,  0x1F,  0x0F,  0x00}; // 우 닫
  char pac3[] = {0x1C,  0x1E,  0x07,  0x03,  0x03,  0x07,  0x1E,  0x1C}; // 좌 벌
  char pac4[] = {0x00,  0x1E,  0x1F,  0x03,  0x07,  0x1F,  0x1E,  0x00}; // 좌 닫
  char enemy[] = {0x0E,  0x1F,  0x15,  0x1F,  0x0E,  0x15,  0x15,  0x15}; // 문어


  // Pacman 이미지를 LCD의 DDRAM에 저장
  lcd_send_cmd(0x40); // LCD 화면의 DDRAM 주소를 설정하여 화면의 원하는 위치에 출력, DDRAM Address 2열 1번의 주소가 0x40
  for(int i = 0 ; i < 8 ; i++)
	  lcd_send_data(pac1[i]);

  lcd_send_cmd(0x40+8); // 8bit씩이니까 2번은 0x40 + 8
  for(int i = 0 ; i < 8 ; i++)
	  lcd_send_data(pac2[i]);

  lcd_send_cmd(0x40+16);
  for(int i = 0 ; i < 8 ; i++)
	  lcd_send_data(pac3[i]);

  lcd_send_cmd(0x40+24);
  for(int i = 0 ; i < 8 ; i++)
	  lcd_send_data(pac4[i]);

  lcd_send_cmd(0x40+32);
  for(int i = 0 ; i < 8 ; i++)
	  lcd_send_data(enemy[i]);
  // 위 코드로 인해 pac1, pac2, pac3, pac4, enemy의 데이터가 LCD 화면에 순서대로 출력된다.
  // lcd_send_cmd()를 사용하여 DDRAM 주소를 설정
  // lcd_send_data()를 사용하여 데이터 출력하면 LCD 화면에 그래픽이 표시된다.

 


 

📌 Game Start

 

게임이 시작되면 버튼이 입력될 때까지 대기하고

조이스틱의 버튼이 눌리면, StartSound가 출력되면서 본 게임이 시작된다.

 

  // 시작 화면
  lcd_put_cur(0, 0);
  lcd_send_string("Press the Button");

  lcd_put_cur(1, 4);
  lcd_send_string("to Start");


  // 시작 버튼 누를 때까지 대기
  while(HAL_GPIO_ReadPin(Joystick_Button_GPIO_Port, Joystick_Button_Pin))
	  HAL_Delay(100); // 눌리면, 100ms 뒤 시작

  HAL_TIM_Base_Start_IT(&htim2); // TIM2를 인터럽트로 사용
  HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // Sound를 위한 PWM

  StartSound();

  lcd_clear();
  lcd_put_cur(0, 1);
  lcd_send_string("LEVEL 1");
  HAL_Delay(500);

  lcd_put_cur(1, 9);
  lcd_send_string("Start!");
  HAL_Delay(800);


  // Pacman Init
  Character pacman;
  memset(&pacman, 0, sizeof(pacman)); // pacman 구조체를 0으로 초기화한다(모든 멤버 변수를 0으로 설정)
  pacman.food = 31;

  // Enemy Init
  Enemy octopus;
  memset(&octopus, 0, sizeof(octopus)); // octopus 구조체를 0으로 초기화한다(모든 멤버 변수를 0으로 설정)
  octopus.image_num = 4;
  octopus.row = 1; // 처음 시작 위치
  octopus.col = 8;


  lcd_clear();
  lcd_put_cur(pacman.row, pacman.col);
  lcd_send_data(pacman.image_num);
//  TIM2->PSC = 8000; // 문어 속도를 좀 더 빠르게 설정


  /* USER CODE END 2 */

 

📌 While 내부

 

Game State = ING

    Pacman과 Enemy가 게임 상태에 따라 계속적으로 LCD에 출력된다.

 

Game State = Win

    게임에서 승리하면(WIN) You WIN Congraturation 문구가 출력된다.

 

Game State = Lose

    게임에서 지면(OVER) You LOSE 문구가 출력된다.

 

while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
	if(game_status == ING)
	{
		Move_Pacman(&pacman, Dir_Joystick());
		Move_Enemy(&octopus, pacman, clk_pulse);

		game_status = GameStatus(&pacman, &octopus);

		HAL_Delay(50);
		LCD_Display_Charactor(&pacman);
		LCD_Display_Enemy(octopus);
		TIM3->CCR1 = 0;
	}
	else if(game_status == WIN)
	{
		lcd_put_cur(0, 4);
		lcd_send_string("YOU WIN");
		lcd_put_cur(1, 0);
		lcd_send_string("Congratulations!");
	}
	else if(game_status == OVER)
	{
		lcd_put_cur(0, 4);
		lcd_send_string("YOU LOSE");
	}
  }
  /* USER CODE END 3 */
}

 


 

📌 Joystick Control

 

조이스틱 값이 디지털 값으로 변환된 후, 배열  dir[ ]에 저장된다.

저장된 값들을 조건에 따라 전후좌우 이동할 수 있도록 함수를 구현하였다.

 

// 조이스틱 방향 출력 함수
Direction Dir_Joystick()
{
	// 중간값에 가까워질수록 민감도 높아짐.
	if(dir[0] > 3150) return LEFT;
	else if(dir[0] < 600) return RIGHT;
	else if(dir[1] > 3150) return UP;
	else if(dir[1] < 600) return DOWN;
	else return NONE;
}

 


 

📌 Level up

 

게임은 총 3단계로 구성되어 있으며,

Level이 올라간 뒤 캐릭터와 적의 위치를 재설정하게 되고

이후 팩맨과 먹이, 적이 LCD상에 출력되고 게임이 정상적으로 실행된다.

 

void LevelupInit(Character *character, Enemy *enemy)
{
	level++; // enum type의 장점, 문자에 각 번호가 할당되어 연산식 사용이 편하다.
	character->row = 0;
	character->col = 0;
	character->food = 31;
	for(int i = 0 ; i <= 1 ; i++)
	{
		for(int j = 0 ; j < 16 ; j++)
		{
			character->past_position[i][j] = 0;
		}
	}
	enemy->row = 1;
	enemy->col = 8;
}

 


 

📌 Character Move

 

조이스틱의 입력 값에 따라 캐릭터가 이동하는 함수를 구현하였고

col 변수는 좌우, row 변수는 상하를 나타낸다.

 

또한 움직임이 한 번 발생할 때마다 Image num이 토글되도록 작성하여

입을 벌린 이미지와 닫은 이미지가 번갈아 가면서 출력되도록 작성했다.

 

캐릭터의 시작은 (0,0)

모든 위치에 한 번씩 다 도달하여 2차원 배열이 전부 1로 채워지면,

먹이를 다 먹었다는 뜻으로 다음 Level로 넘어가게 된다.

 

void Move_Pacman(Character *character, Direction direc)
{
	switch(direc)
	{
	case RIGHT :
		character->col++; // RIGHT 입력 시, 1칸 이동
		if(character->col > 15) character->col = 15; // 칸 넘어가지 말고 그 자리에서 정지
		character->image_num &= ~(0x2); // 오른쪽 방향 이미지 : 0, 1
		character->image_num ^= 1;
		break;

	case LEFT :
		character->col--;
		if(character->col < 0) character->col = 0;
		character->image_num |= 0x2; // 왼쪽 방향 이미지 : 2, 3
		character->image_num ^= 1;
		break;

	case UP :
		character->row--;
		if(character->row < 0) character->row = 0;
		break;

	case DOWN :
		character->row++;
		if(character->row > 1) character->row = 1;
		break;

	default :
		break;
	}
	character->past_position[character->row][character->col] = 1;
	// 캐릭터 시작은 0,0
	// 모든 위치에 한 번씩 다 도달하여 이차원배열이 1로 채워지면,
	// 먹이를 다 먹었다는 뜻.
}

 


 

📌 Display Charactor

 

캐릭터를 LCD에 출력하는 함수이며,

Past_position 배열을 이용하여 캐릭터가 지나가지 않은 자리에만

먹이를 배치하도록 함수를 구성하였다.

 

또한 LCD 점멸 현상을 방지하기 위해

lcd_clear() 함수 대신,

캐릭터가 지나간 자리에는 빈 칸으로 OverWrite되도록 설계하여

LCD가 부드럽게 출력된다.

 

먹이를 먹을 경우, Buzzer를 통하여 sound가 출력되도록 구성하였다.

 

void LCD_Display_Charactor(Character *character)
{
	uint8_t count = 0; // 먹이의 갯수를 세는 변수 count
//	lcd_clear(); // 클리어하지 말고 OverWrite하면 점멸 현상 대부분 완화됨.
	lcd_put_cur(character->row, character->col); // 캐릭터의 현재 위치로 커서 이동
	lcd_send_data(character->image_num); // 캐릭터 이미지 데이터를 LCD에 출력

	// 캐릭터 먹이 생성
	for(int i = 0 ; i <= 1 ; i++)
	{
		for(int j = 0 ; j < 16 ; j++)
		{
			if (character->past_position[i][j] != 1) // pacman이 지나가지 않은 곳에 먹이 생성
			{
				lcd_put_cur(character->row, character->col); // 캐릭터의 현재 위치로 커서 이동
				lcd_send_data(character->image_num);
				lcd_put_cur(i, j); // 지나간 위치 빼고 모든 위치에 먹이 배치
				lcd_send_data(0xa5); // 먹이 모양 : 0xa5 / LCD 데이터 시트 참조
				count++;
			}
			else if(character->past_position[i][j] = 1) // 먹이를 먹은 자리만 clear(OverWrite하기 위해)
			{
				lcd_put_cur(character->row, character->col); // 캐릭터의 현재 위치로 커서 이동
				lcd_send_data(character->image_num);
				lcd_put_cur(i, j);
				lcd_send_data(0x20); // 빈 칸 데이터 : 0010 0000(=0x20)
			}
		}
	}
	// 생성된 먹이의 갯수와 캐릭터가 먹은 먹이의 수를 비교하여 소리 조절
	if(count < character->food)
	{
		// PWM 사용하여 소리 출력
		TIM3->CCR1 = TIM3->ARR / 2;
		character->food = count;
	}
}

 


 

📌 Enemy Display

 

처음에는 함수를 따로 만들어 아래와 같이 구성하였는데,

변수 동기화 등의 문제로 Move Enemy 함수에 함쳐서 사용하였다.

 

void LCD_Display_Enemy(Enemy enemy)
{
	lcd_put_cur(enemy.row, enemy.col);
	lcd_send_data(enemy.image_num);
}

 


 

📌 Move Enemy

 

Enemy의 움직임을 구현하는 함수이다.랜덤으로 움직이도록 하기 위해 Rand() 함수를 사용했고,캐릭터를 잡으러 가는 설정을 위해 Enemy가 움직일 때 캐릭터 위치로 이동하도록 함수를 설계하였다.

 

Enemy는 TIM2의 인터럽트 발생 시,변수 clk_pulse가 토글되고 이 신호에 따라서 움직인다.

 

void Move_Enemy(Enemy *enemy, Character character, uint8_t clk_pulse) // enemy는 포인터 변수, charactor는 구조체이므로 enemy->col, charaoctr->col
{
	uint8_t move = rand()%2; // 0 또는 1 중의 무작위값 선택

	if(clk_pulse == 1 && enemy->clock_before == 0)
	{
		if(move == 0)
		{
			if(enemy->row != character.row) // Enemy와 Charactoc의 행이 다른 경우
			{
				enemy->row = character.row; // Enemy는 Charactor쪽으로 이동한다.
				lcd_put_cur(enemy->row, enemy->col);
				lcd_send_data(enemy->image_num);
			}
		}
		else if(move == 1)
		{
			if(enemy->col > character.col) // enemy가 charactor보다 오른쪽에 있다면,
			{
				enemy->col--; // 왼쪽으로 이동시켜라.
				lcd_put_cur(enemy->row, enemy->col);
				lcd_send_data(enemy->image_num);
				if(enemy->col < 0) // 화면 밖으로 나가지 않도록 설정
					enemy->col = 0;
			}
			else if(enemy->col < character.col) // enemy가 charactor보다 왼쪽에 있다면,
			{
				enemy->col++; // 오른쪽으로 이동시켜라
				lcd_put_cur(enemy->row, enemy->col);
				lcd_send_data(enemy->image_num);
				if(enemy->col > 15) // 화면 밖으로 나가지 않도록 설정
					enemy->col = 15;
			}
		}
	}
	enemy->clock_before = clk_pulse;
}

 


 

📌 Game Status

 

Level에 따른 게임의 진행을 구현한 함수이다.레벨이 올라갈수록 난이도가 높아지는 것을 PSC값을 조정하여Enemy의 움직임이 빨라지도록 설계하였다.

 

Game_status GameStatus(Character *character, Enemy *enemy)
{
	uint8_t cnt = 0;
	for(int i = 0 ; i <= 1 ; i++)
	{
		for(int j = 0 ; j < 16 ; j++)
		{
			if(character->past_position[i][j] == 1)
				cnt++;
		}
	}
	if(character->row == enemy->row && character->col == enemy->col)
	{
		LoseSound();
		return OVER;
	}
	else if(cnt == 32 && level == 1)
	{
		WinSound();
		lcd_clear();
		lcd_put_cur(0, 1);
		lcd_send_string("Level 2");
		HAL_Delay(500);
		lcd_put_cur(1, 9);
		lcd_send_string("Start!");
		HAL_Delay(800);
		LevelupInit(character, enemy);
		TIM2->PSC = 8750; // 문어 속도 Lv1보다 빠르게
//		TIM2->PSC = 5000; // 문어 속도 Lv1보다 빠르게
		return game_status;
	}
	else if(cnt == 32 && level == 2)
	{
		WinSound();
		lcd_clear();
		lcd_put_cur(0, 1);
		lcd_send_string("Level 3");
		HAL_Delay(500);
		lcd_put_cur(1, 9);
		lcd_send_string("Start!");
		HAL_Delay(800);
		LevelupInit(character, enemy);
		TIM2->PSC = 6500; // 문어 속도 Lv2보다 빠르게
//		TIM2->PSC = 3500; // 문어 속도 Lv2보다 빠르게
		return game_status;
	}
	else if(cnt == 32 && level == 3)
	{
		WinSound();
		return WIN;
	}
	else return game_status;
}

 


 

📌 Interrupt Callback

 

Enemy의 움직임을 위한 clk_pulse를 발생시키는 기능을인터럽트 콜백 함수에 넣어서 인터럽트 발생 시마다 동작하도록 구성하였다.

 

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
	clk_pulse ^= 0x01;
}

 


 

📌 Sound

 

게임을 진행하면서 Buzzer를 통해 출력되는 Sound들의 함수이다.

PWM의 ARR값과 Delay를 조절하여 Sound를 설계하였고,

 

Sound의 Source는 아래 링크의 유튜버를 참고하였다.

https://www.youtube.com/watch?v=HU-3VD1_Pgg 

 

void StartSound()
{
	TIM3->ARR = 156;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(100);
	TIM3->ARR = 111;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(100);

	// Setting for food eating Sound
	TIM3->ARR = 1060;
	TIM3->CCR1 = 0;
}


void LoseSound()
{
	TIM3->ARR = 290;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(80);

	TIM3->ARR = 391;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(80);

	TIM3->ARR = 290;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(80);

	TIM3->ARR = 391;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(80);
}


void WinSound()
{
	TIM3->ARR = 593;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(100);

	TIM3->CCR1 = 0;
	HAL_Delay(10);

	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(100);

	TIM3->CCR1 = 0;
	HAL_Delay(10);

	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(100);

	TIM3->CCR1 = 0;
	HAL_Delay(10);

	TIM3->ARR = 767;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(300);

	TIM3->ARR = 593;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(300);

	TIM3->ARR = 508;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(300);

	TIM3->ARR = 1029;
	TIM3->CCR1 = TIM3->ARR / 2;
	HAL_Delay(300);
}

 

Fin

 


 

[참조 자료]

 

   ✅ 프로젝트 참조 : Pacman 프로젝트 by 개준생의 공부일지

   I2C 참조 : I2C Library by ControllersTech

    Sound 참조 : Sound Source by Rafaël Busschop

 


 

728x90
반응형