[Mission]
📌 Specification
<개인 프로젝트>
✅ 제작 기간 : 2023.09.06~2023.09.08 (3 Days)
✅ Board : STM32 Nucleo - F411RE
✅ Tool : STM32CubeIDE
📌 Mission
: STM32 board와 각종 기능들을 활용하여 Pacman 게임을 구현해보자.
M1. 조이스틱을 이용하여 pacman의 움직임을 제어한다.
M2. 통신 방식(UART, I2C 등) 중 하나를 선택하여 LCD로 게임을 출력한다.
M3. Buzzer를 사용하여 게임 중 발생하는 Sound를 구현한다.
M4. Enemy가 랜덤으로 이동하도록 설정
[Result]
📌 Operation
Game Start
Level Up
Win
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 |
📌 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 셋팅까지 함께 해준다.
📌 Timer Setting
Enemy의 움직임을 위한 Timer
초기 셋팅은 1Hz로 설정해주고, 게임 진행 Level에 따라 PSC를 조절하여 Enemy의 움직임을 빠르게 변화시켜줄 것이다.
📌 Buzzer Setting
Buzzer
PWM Setting
TIM3는 Passive Buzzer를 제어하기 위해 PWM mode로 Setting했다.
📌 I2C Setting
우리가 사용할 LCD는 I2C 통신이 가능하도록 I2C Interface adapter가 장착되어 있다.
일반 1602 LCD를 보면, 16개의 핀이 달려 있는데 이 어댑터가 하드웨어적으로 좀 더 간편하게 통신할 수 있다.
I2C 통신을 사용하기 위해서는 I2C 관련 함수들을 만들어줘야 하는데,
해당 I2C 라이브러리는 아래의 유튜버가 공유한 라이브러리를 merge하여 사용할 것이다.
LCD 모듈에는 CGROM이라는 저장 공간이 존재하는데, 이 저장소에는 기본적으로 사용할 수 있는 문자들이 저장되어 있다.
한 가지 단점은 일본에서 만든 제품이기 때문에 한국어 지원이 안 된다는 점이다.
하지만 방법이 없지는 않다.
CGRAM이라는 저장 공간도 존재하는데, 이 저장 공간에 5x8 도트의 최대 8개의 커스텀 문자를 저장하여 사용할 수 있고
이 방법을 활용하여 아래와 같이 Pacman과 Enemy의 도트를 직접 찍어서 사용할 것이다.
통신 모드는 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