OpenGL Game Project + 테트리스 만들기

소개

이번 글은 기존 OpenGL 프로젝트 위에 테트리스를 붙이면서,
어떤 순서로 구현했고 왜 그런 구조로 나눴는지를 튜토리얼 형태로 정리한 글입니다.

아래에 깃허브 사이트를 링크했습니다.

tetris github

최종적으로 만든 기능은 다음과 같습니다.

  • 스프라이트 기반 블록 렌더링
  • 점수, 라인, 레벨 HUD
  • 다음 블록 미리보기
  • 홀드 기능
  • 고스트 피스
  • 콤보 점수
  • 일시정지
  • 라인 클리어 연출
  • 배경 파티클
  • 합성 사운드

이 글은 단순히 “기능 목록”을 보여주기보다,
어떤 순서로 만드는 것이 덜 꼬이고 유지보수도 쉬운지를 기준으로 설명합니다.


최종 구조 먼저 보기

처음부터 구조를 잘게 나누기보다, 먼저 빠르게 동작하게 만든 뒤 점점 분리하는 방식으로 진행했습니다.
최종적으로는 다음 구조로 정리했습니다.

MainWindow

앱 전체 생명주기 담당

  • 장면 초기화
  • 프레임 업데이트 호출
  • 창 제목 갱신
  • 창 크기 변경 대응

TetrisGame

게임 규칙 담당

  • 보드 상태
  • 현재 피스 / 다음 피스 / 홀드 피스
  • 이동, 회전, 드롭
  • 줄 삭제
  • 점수, 레벨, 콤보
  • 게임 오버 / 일시정지

TetrisRenderer

렌더링 담당

  • 보드
  • 현재 피스
  • 고스트 피스
  • HOLD / NEXT 패널
  • HUD
  • 라인 클리어 플래시
  • 파티클

TetrisInputController

입력 해석 담당

  • 좌우 반복 이동
  • 소프트 드롭 반복
  • 회전
  • 홀드
  • 하드 드롭
  • 재시작 / 일시정지

TetrisAudio

사운드 담당

  • 홀드
  • 라인 클리어
  • 하드 드롭
  • 레벨업
  • 일시정지
  • 게임 오버

이 구조를 먼저 머리에 두고 시작하면, 각 단계에서 왜 파일을 나눴는지가 훨씬 이해가 쉽습니다.


1단계. 먼저 “돌아가는 테트리스”를 만든다

처음에는 시각 효과보다 규칙이 먼저입니다.
테트리스가 최소한 게임으로 동작하려면 다음이 필요합니다.

  • 보드 10x20
  • 테트로미노 정의
  • 이동과 회전
  • 중력 낙하
  • 줄 삭제
  • 점수 계산

이 단계는 TetrisGame.cpp 중심으로 구현했습니다.

핵심 구현 포인트

1. 테트로미노를 4x4 배열로 저장하기

각 블록을 회전할 때마다 계산하는 대신,
애초에 4개의 회전 상태를 모두 저장해두면 훨씬 단순해집니다.

이 방식의 장점은 다음과 같습니다.

  • 이동 판정이 단순해짐
  • 회전 판정이 단순해짐
  • 고스트 피스 계산도 같은 데이터 사용 가능
  • 스폰 가능 여부 검사도 재사용 가능

2. 충돌 판정을 하나의 함수로 모으기

doesPieceFit() 같은 함수를 만들고,
이동/회전/고스트/홀드/스폰 검사를 모두 이 함수로 통일하는 것이 중요합니다.

왜냐하면 테트리스 버그 대부분은 “충돌 판정이 제각각인 상태”에서 생기기 때문입니다.

예를 들어:

  • 이동은 되는데 회전은 이상함
  • 고스트 피스 위치가 다름
  • 홀드 후 스폰이 겹침

이런 문제를 줄이려면 충돌 판정을 반드시 한 곳으로 모아야 합니다.

3. 7-bag 시스템 적용하기

무작위 블록 생성은 그냥 rand()로 뽑아도 되지만,
테트리스는 7-bag 방식을 쓰면 훨씬 플레이 감각이 안정적입니다.

7-bag 방식은:

  • 7종류 블록을 한 번씩 담고
  • 섞은 뒤
  • 순서대로 꺼내는 방식입니다

이렇게 하면 특정 블록이 너무 오래 안 나오는 문제를 줄일 수 있습니다.


2단계. MainWindow에 몰아넣지 말고, 게임 규칙부터 분리한다

처음에는 MainWindow 안에서 게임 상태를 직접 다뤄도 됩니다.
하지만 기능이 조금만 늘어나면 금방 복잡해집니다.

그래서 테트리스 규칙은 TetrisGame.h, TetrisGame.cpp로 분리했습니다.

왜 먼저 TetrisGame을 분리했나?

이유는 간단합니다.

  • 렌더링은 바뀔 수 있다
  • 입력 방식도 바뀔 수 있다
  • 하지만 게임 규칙은 가장 오래 유지된다

즉, 가장 먼저 안정시켜야 하는 코어가 TetrisGame입니다.

이 구조를 만들어 두면 이후에:

  • HUD를 바꾸든
  • 스프라이트를 바꾸든
  • 파티클을 추가하든
  • 사운드를 붙이든

게임 규칙은 건드리지 않고 확장할 수 있습니다.


3단계. 컬러 사각형 대신 스프라이트 블록으로 바꾸기

기본 규칙이 완성되면 이제 화면을 게임답게 만들 차례입니다.

이번 프로젝트에서는 한 칸짜리 블록 이미지들을 사용했습니다.

  • red_block.png
  • blue_block.png
  • yellow_block.png
  • green_block.png
  • purple_block.png

렌더링은 TetrisRenderer.cpp에서 처리하도록 분리했습니다.

구현 방식

  1. 블록 이미지들을 Sprite로 로드
  2. shape 인덱스를 적절한 스프라이트 인덱스로 매핑
  3. 보드 셀마다 대응하는 스프라이트를 그림

여기서 중요했던 문제

실행 경로에 따라 이미지 경로가 어긋날 수 있다는 점입니다.

예를 들어:

  • .\\Image\\...
  • .\\OpenGLGame\\Image\\...

실행 위치가 다르면 둘 중 하나만 맞는 경우가 생깁니다.
그래서 파일 존재 여부를 확인하면서 후보 경로를 순서대로 찾는 방식으로 처리했습니다.

이 방식은 나중에 아이콘이나 다른 자산을 읽을 때도 재사용하기 좋습니다.


4단계. HUD 만들기

게임이 돌아가도 점수와 상태가 안 보이면 완성도가 확 떨어집니다.
그래서 다음 요소를 순서대로 추가했습니다.

  • 점수
  • 라인 수
  • 레벨
  • 다음 블록 패널
  • HOLD 패널
  • 게임 오버 오버레이

관련 코드는 TetrisRenderer.cpp, TetrisHudConfig.h에 있습니다.

HUD를 구현하면서 했던 선택

일반 텍스트 폰트를 붙이는 대신,
픽셀 스타일 라벨과 블록형 숫자를 직접 그리는 방식으로 갔습니다.

이 방식의 장점은:

  • 프로젝트 전체 분위기와 잘 맞음
  • 별도 텍스트 렌더러가 필요 없음
  • 블록 이미지와 시각 스타일이 통일됨

즉, 기술적으로 더 화려한 방식보다
현재 프로젝트에 어울리는 방식을 선택한 셈입니다.


5단계. 입력을 별도 컨트롤러로 분리하기

처음에는 입력 처리를 MainWindow에서 직접 해도 됩니다.
하지만 키 반복 이동, 소프트 드롭 반복, 일시정지 같은 로직이 늘어나면
게임 규칙과 입력 규칙이 섞이기 시작합니다.

그래서 TetrisInputController.cpp로 입력을 분리했습니다.

입력 계층에서 처리한 것

  • Left, Right 반복 이동
  • Down 소프트 드롭 반복
  • Up, Space, Z 회전
  • Shift, H 홀드
  • C 하드 드롭
  • P 일시정지
  • R 재시작

왜 이 분리가 중요할까?

예를 들어 “왼쪽 키를 누르고 있으면 0.12초마다 이동”은 입력 장치의 감도 문제입니다.
반대로 “현재 이 위치로 이동 가능한가?”는 게임 규칙 문제입니다.

이 둘을 분리하면:

  • 입력 장치가 달라져도 게임 규칙은 유지됨
  • 나중에 패드 입력을 붙이기 쉬움
  • 키 반복 버그를 잡기 쉬움

6단계. 고급 게임 기능 붙이기

기본적인 테트리스가 완성되면 이제 플레이 감을 올려야 합니다.
이 단계에서 붙인 핵심 기능은 다음과 같습니다.

홀드 기능

관련 코드: TetrisGame.cpp

구현 규칙:

  • 첫 홀드는 현재 피스를 저장하고 다음 피스를 가져옴
  • 이후부터는 저장 피스와 현재 피스를 교환
  • 한 턴에 한 번만 홀드 가능

이 제한이 중요합니다.
제한이 없으면 홀드를 반복해서 난이도가 크게 무너집니다.

고스트 피스

관련 코드: TetrisGame.cpp, TetrisRenderer.cpp

현재 피스를 복사한 뒤, 충돌 직전까지 아래로 내린 결과를 계산해 반투명하게 그렸습니다.

이 기능은 생각보다 체감 차이가 큽니다.

  • 하드 드롭 위치를 바로 알 수 있음
  • 미세 조정이 쉬워짐
  • 초보자도 착지 위치를 읽기 쉬워짐

콤보 점수

연속으로 줄을 지울 때 보상을 주기 위해 추가했습니다.

규칙은 단순합니다.

  • 줄을 지우면 콤보 증가
  • 줄을 못 지우면 콤보 초기화
  • 기본 라인 점수 + 콤보 보너스

일시정지

일시정지는 단순히 게임 업데이트만 멈추는 것이 아닙니다.

  • 입력 반복 상태 초기화
  • HUD 오버레이 표시
  • 창 제목 갱신
  • 일시정지 사운드 재생

이런 부분까지 함께 맞춰줘야 실제 게임처럼 자연스럽게 느껴집니다.


7단계. 라인 클리어 연출 넣기

테트리스에서 줄이 지워질 때 바로 사라지게 하면 기능적으로는 맞지만 밋밋합니다.
그래서 이번 프로젝트에서는 줄 삭제를 두 단계로 나눴습니다.

1. 삭제 대상 줄 먼저 표시

beginLineClear()에서 하는 일:

  • 가득 찬 줄을 찾음
  • 바로 삭제하지 않고 마킹만 함
  • 렌더러가 이 줄을 플래시할 수 있게 함

2. 잠깐 멈춘 뒤 실제로 삭제

finalizeLineClear()에서 하는 일:

  • 마킹된 줄을 제외하고 보드를 다시 재구성
  • 위쪽 빈 줄 채움
  • 점수 반영
  • 이벤트 카운터 증가
  • 다음 피스 스폰

이 방식의 장점

  • 라인 클리어 플래시 가능
  • 파티클 타이밍 맞추기 쉬움
  • 오디오 타이밍 맞추기 쉬움
  • “어떤 줄이 지워졌는지”를 외부 시스템이 공유 가능

즉, 규칙과 연출을 분리하면서도 서로 연결되는 구조를 만들 수 있습니다.


8단계. 배경 파티클과 버스트 파티클

게임 화면이 밋밋하지 않게 하기 위해 파티클도 추가했습니다.

관련 코드는 TetrisRenderer.cpp에 있습니다.

배경 파티클

화면 뒤쪽에서 천천히 떠다니는 작은 사각형들입니다.

특징:

  • 텍스처 없이 사각형만 사용
  • 비용이 적음
  • 화면이 정적이지 않게 만듦

버스트 파티클

짧게 터지는 이펙트입니다.

사용 시점:

  • 라인 클리어
  • 하드 드롭 착지

이 버스트는 아주 짧은데도 체감이 큽니다.
“내 입력이 화면에 강하게 반영된다”는 느낌을 주기 때문입니다.


9단계. 사운드 붙이기

사운드는 처음에는 Beep로 빠르게 붙였지만, 소리가 너무 시스템 알림처럼 느껴졌습니다.
그래서 최종적으로는 TetrisAudio.cpp에서 PCM 샘플을 직접 생성해 재생하도록 바꿨습니다.

구현 방식

  1. 이벤트별 톤 정의
  2. 사인파 기반 샘플 생성
  3. 약한 배음 추가
  4. attack / release 엔벨로프 적용
  5. waveOut로 재생

왜 이벤트 기반으로 만들었나?

오디오가 키 입력을 직접 보면 타이밍이 어긋날 수 있습니다.

예를 들어:

  • 줄 삭제는 입력 직후가 아니라 애니메이션 종료 후 발생
  • 홀드는 실제 교체 성공 시점이 중요
  • 게임 오버는 상태 확정 시점이 중요

그래서 TetrisGame 안에서 이벤트 카운터를 올리고,
TetrisAudio는 이 카운터 변화를 감지해서 사운드를 재생하도록 했습니다.

이 구조가 훨씬 정확하고 확장하기도 쉽습니다.


10단계. 레벨별 속도 커브 튜닝

테트리스는 속도 조절이 매우 중요합니다.
단순히 level * constant 방식으로 떨어뜨리면 플레이 감이 쉽게 무너집니다.

그래서 이번에는 직접 손으로 조정한 속도 테이블을 사용했습니다.

의도는 다음과 같습니다.

  • 초반은 천천히 익힐 수 있게
  • 중반은 확실히 긴장감이 올라오게
  • 후반은 숙련자도 집중해야 하게

그리고 테이블 끝 이후에도 조금씩 더 빨라지게 만들어서,
오래 플레이해도 난이도가 정체되지 않게 했습니다.


11단계. 빌드 문제 해결하기

기능을 만드는 것만큼 중요한 것이 빌드 안정화였습니다.

Release x64 빌드 문제

원인은 프로젝트 설정이었습니다.

  • 라이브러리 경로가 실제 glfw 위치와 맞지 않았음
  • 릴리스 구성의 PCH 설정이 디버그와 다르게 맞지 않았음

그래서:

  • LibraryPath 수정
  • Release x64 PCH 설정 정리
  • pch.cpp의 릴리스 설정 보완

이 작업으로 Debug x64, Release x64 모두 정상 빌드되게 만들었습니다.

리소스 경로 문제

이미지 경로가 실행 위치에 따라 달라져 텍스처가 안 보이는 문제도 있었는데,
후보 경로를 순서대로 검사하는 방식으로 해결했습니다.

한글 주석 인코딩 문제

마지막에 주석을 한국어로 바꾸면서 C4819 경고가 발생했는데,
관련 파일을 UTF-8로 저장해 정리했습니다.


12단계. 마무리: 주석과 문서화

기능이 다 들어간 뒤에는 주석 정리도 중요합니다.
특히 다음 부분은 나중에 다시 봐도 바로 이해되게 설명이 있어야 합니다.

  • 줄 삭제를 왜 2단계로 나눴는지
  • 충돌 판정을 왜 한 함수로 모았는지
  • 파티클이 어떤 이벤트에 반응하는지
  • 사운드가 왜 입력이 아니라 이벤트를 보는지
  • 입력 반복을 왜 게임 로직 밖에서 처리하는지

그래서 복잡한 파일 위주로 한국어 주석을 추가했습니다.

  • MainWindow.cpp
  • TetrisGame.cpp
  • TetrisRenderer.cpp
  • TetrisAudio.cpp
  • TetrisInputController.cpp

정리

이번 테트리스 작업은 “블록을 떨어뜨리는 예제”를 넘어서,
기존 OpenGL 프로젝트를 실제 플레이 가능한 게임 구조로 확장하는 과정이었습니다.

핵심 순서를 다시 요약하면 다음과 같습니다.

  1. 먼저 최소한의 테트리스 규칙을 구현한다.
  2. 게임 규칙을 TetrisGame으로 분리한다.
  3. 스프라이트 기반 렌더링으로 교체한다.
  4. HUD와 패널을 추가한다.
  5. 입력을 별도 컨트롤러로 분리한다.
  6. 홀드, 고스트 피스, 콤보를 추가한다.
  7. 라인 클리어 연출과 파티클을 붙인다.
  8. 사운드를 이벤트 기반으로 연결한다.
  9. 속도 커브를 다듬는다.
  10. 빌드와 인코딩 문제까지 정리한다.

이 순서대로 가면 기능이 늘어나도 코드가 비교적 덜 꼬이고,
중간중간 테스트하면서 확장하기도 편합니다.


다음에 확장해볼 만한 것

여기서 더 발전시키려면 다음 기능도 충분히 붙일 수 있습니다.

  • 타이틀 화면 / 메뉴 화면
  • 실제 WAV/MP3 효과음 자산 적용
  • 랭킹 저장
  • 키 설정 변경
  • 고급 회전 규칙(SRS) 확장
  • 모바일/패드 입력 대응

지금 구조는 이런 확장을 고려해도 크게 무리 없는 상태까지 정리되어 있습니다.

Leave a comment