C++ 게임 엔진을 Gymnasium 환경으로 감싸고 DQN으로 학습하는 실전 프로젝트
이 프로젝트의 핵심은 테트리스를 단순한 화면 게임으로 보지 않고, 에이전트가 반복해서 시행착오를 겪을 수 있는 학습 환경으로 바꾸는 데 있다. 게임 규칙은 C++ 엔진이 책임지고, 파이썬은 그 엔진을 호출해 관측과 보상을 받아 DQN 모델을 학습한다.
항목 | 내용 |
|---|---|
게임 엔진 | 레포 내부 tetris/src/tetris_engine.* 사용 |
학습 환경 | local_tetris_env.py의 Gymnasium Env |
행동 공간 | 기본 Discrete(7), 매크로 모드 Discrete(4 * board_width) |
관측 | hidden rows를 제외한 visible grid, shape=(22, 10, 1), dtype=uint8 |
학습 알고리즘 | Stable-Baselines3 DQN |
모델 저장 | model/live_latest.zip 또는 지정한 이름의 zip 파일 |
강화학습 튜토리얼에서는 이미 만들어진 Gym 환경을 자주 사용한다. 그러나 실제 프로젝트에서는 환경 자체가 제품의 일부인 경우가 많다. 이 장의 테트리스 프로젝트도 그렇다. 외부 ROM이나 gym-tetris에 의존하지 않고, 우리가 소유한 C++ 엔진을 학습 대상으로 삼는다. 이 방식은 처음에는 조금 번거롭지만 세 가지 장점이 있다.
코드 흐름은 아래처럼 네 층으로 나뉜다.
C++ TetrisEngine
-> extern C bridge: tetris_c_api.cpp
-> Python ctypes wrapper: local_tetris_env.py
-> DQN training loop: train_tetris_rl.py이 구조에서 가장 중요한 경계는 C++ 엔진과 Python 환경 사이의 경계다. 엔진은 게임을 한 스텝 진행하고, 파이썬은 그 결과를 강화학습 라이브러리가 이해할 수 있는 형태로 바꾼다.
파일 | 챕터에서 설명할 역할 |
|---|---|
tetris/src/tetris_engine.* | 테트리스 규칙, 조각 이동, 라인 삭제, 보상 계산을 담당하는 C++ 코어 엔진 |
tetris/src/tetris_c_api.cpp | C++ 엔진을 파이썬에서 부를 수 있도록 extern C 함수로 감싸는 브릿지 |
local_tetris_env.py | C API를 ctypes로 호출해 Gymnasium Env 형태로 제공하는 핵심 환경 |
train_tetris_rl.py | Stable-Baselines3 DQN 모델을 만들고 학습, 저장, 이어학습을 처리하는 실행 스크립트 |
macro_planning_env.py | 저수준 키 입력 대신 한 조각의 목표 회전과 목표 열을 고르는 매크로 행동 환경 |
llm_reward_wrapper.py | 로컬 LLM/VLM이 보드 상태를 평가해 보상에 보조 신호를 더하는 래퍼 |
qt_rl_client.py / train_qt_live.py | 학습된 모델을 Qt GUI와 연결하거나 학습 과정을 시각화하는 클라이언트 |
엔진의 중심은 TetrisEngine 클래스다. 이 클래스는 보드 크기, 숨겨진 스폰 영역, 미리보기 조각 수, 라인 삭제 보상, 높이와 구멍 패널티 같은 설정을 가진다. 강화학습 입장에서 엔진은 reset과 step을 제공하는 시뮬레이터다.
enum class TetrisAction : int8_t {
Noop = 0, Left = 1, Right = 2,
RotateCW = 3, RotateCCW = 4,
SoftDrop = 5, HardDrop = 6, Count = 7
};행동은 총 7개다. 왼쪽, 오른쪽, 회전, 소프트 드롭, 하드 드롭처럼 사람이 키보드로 누르는 조작을 거의 그대로 모델의 행동 공간으로 삼는다. 이렇게 하면 환경은 직관적이지만 학습은 쉽지 않다. 모델은 한 조각을 어디에 둘지 장기적으로 판단해야 하는데, 실제 행동은 한 칸씩 이동하는 미세 조작이기 때문이다.
파이썬이 C++ 클래스를 직접 다루기는 어렵다. 그래서 tetris_c_api.cpp는 TetrisEngine을 TetrisHandle이라는 불투명 핸들로 감싸고, 파이썬에서 호출하기 쉬운 C 스타일 함수들을 제공한다.
TetrisHandle* tetris_create(...);
void tetris_reset(TetrisHandle* h);
int tetris_step(TetrisHandle* h, int action);
double tetris_last_reward(const TetrisHandle* h);
int tetris_fill_render_grid(TetrisHandle* h, uint8_t* out, int out_len);여기서 tetris_fill_render_grid가 관측을 만드는 핵심 함수다. 잠긴 블록과 현재 떨어지는 블록을 합친 보드를 row-major uint8 배열로 채워 준다. 강화학습 모델은 이 배열을 보고 다음 행동을 고른다.
local_tetris_env.py는 ctypes로 동적 라이브러리를 열고, C API 함수들의 인자 타입과 반환 타입을 선언한다. 그 다음 LocalTetrisEnv가 Gymnasium의 reset과 step 규약에 맞춰 엔진을 호출한다.
self.action_space = spaces.Discrete(7)
self.observation_space = spaces.Box(
low=0,
high=7,
shape=(self._visible_h, self._w, 1),
dtype=np.uint8,
)관측에서 숨겨진 행을 제거하는 것도 중요하다. C++ 엔진은 총 26행을 갖지만 상단 4행은 조각 스폰을 위한 숨겨진 영역이다. 학습 입력은 실제 화면에 보이는 22행만 사용한다.
강화학습에서 보상은 모델에게 주는 유일한 방향 신호다. 테트리스에서 단순히 오래 살아남기만 보상하면 모델은 라인을 지우기보다 버티는 행동을 배울 수 있다. 반대로 라인 삭제만 크게 보상하면 높은 탑을 쌓다가 갑자기 게임이 끝나는 전략을 배울 수도 있다.
항목 | 내용 |
|---|---|
라인 삭제 보상 | 1줄=1.0, 2줄=3.0, 3줄=10.0, 4줄=30.0 |
스택 높이 패널티 | 새 블록이 쌓인 뒤 높이가 증가하면 패널티 |
울퉁불퉁함 패널티 | 인접 열 높이 차이가 클수록 패널티 |
절대 높이 패널티 | 보드가 높게 쌓일수록 패널티 |
구멍 패널티 | 블록 아래 빈칸이 생기면 패널티 |
config/rl_config.py는 이 보상 스케일을 한곳에서 관리한다. 장의 실습에서는 먼저 기본 설정으로 학습하고, 이후 패널티 값을 바꿔가며 플레이 스타일이 어떻게 달라지는지 비교한다.
train_tetris_rl.py는 설정 파일을 읽고, 환경을 만들고, DQN 모델을 학습한 뒤 model 디렉토리에 저장한다. Stable-Baselines3는 관측 공간을 보고 CNN 정책을 쓸지 MLP 정책을 쓸지 선택할 수 있는데, 이 프로젝트는 이미지형 보드 관측이므로 CnnPolicy가 자연스럽다.
python train_tetris_rl.py
# 설정 파일을 바꿔 실행하고 싶다면
python train_tetris_rl.py --config config/rl_config.py --save-name dqn_first_run학습 결과는 기본적으로 model/live_latest.zip에 저장된다. 기존 모델이 있으면 .bak 파일로 백업한 뒤 새 모델을 저장하므로, 실험을 반복해도 마지막 결과를 쉽게 찾을 수 있다.
저수준 행동 공간은 사람이 보기에는 자연스럽지만 모델에게는 어렵다. 한 조각을 왼쪽으로 여러 번 옮기고 회전한 뒤 하드 드롭해야 하는데, 그 모든 중간 행동이 올바르게 이어져야 보상이 나온다. macro_planning_env.py는 이 문제를 줄이기 위해 행동을 '목표 열과 목표 회전'으로 바꾼다.
action = target_col * 4 + target_rot보드 폭이 10이라면 매크로 행동은 40개다. 모델은 더 이상 매 프레임 왼쪽 키를 누를지 오른쪽 키를 누를지 고민하지 않는다. 대신 현재 조각을 몇 번째 열에 어떤 회전 상태로 놓을지만 고른다. 환경 내부는 그 목표를 향해 회전, 이동, 하드 드롭을 자동으로 실행한다.
llm_reward_wrapper.py는 학습 환경 위에 한 겹 더 씌우는 래퍼다. 원래 환경 보상에 로컬 LLM 또는 VLM이 평가한 점수를 더한다. 예를 들어 보드가 평평한지, 구멍이 많은지, 다음 행동이 위험해 보이는지 같은 신호를 보조 보상으로 사용할 수 있다.
reward_shaped = env_reward + alpha * llm_score헤드리스 학습은 빠르지만 모델이 실제로 어떻게 플레이하는지 감이 잘 오지 않는다. Qt 앱은 TCP 포트를 열고, 파이썬 클라이언트가 학습된 모델로 행동을 보내게 한다.
./tetris/build/tetris_rl --rl-port 5555
python qt_rl_client.py --port 5555 --model model/live_latest.zip --deterministic --auto-reset이 장의 마지막 실습에서는 학습된 모델을 GUI에 연결해 실제 플레이를 관찰한다. 여기서 모델이 자주 만드는 실수를 메모하면 다음 실험의 보상 설계가 훨씬 명확해진다.
이번 장의 핵심은 강화학습 알고리즘 자체보다 '학습 가능한 환경을 만드는 힘'이다. 테트리스 엔진을 직접 만들고, C API로 공개하고, Gymnasium Env로 감싸고, DQN으로 학습시키는 전체 흐름을 경험하면 다른 게임이나 시뮬레이터에도 같은 패턴을 적용할 수 있다.
다음 장에서는 이 프로젝트를 더 발전시켜 학습 로그를 분석하고, 여러 보상 설정의 결과를 비교하며, 모델의 행동을 평가하는 자동 리포트를 만들어 본다.
댓글 0
아직 댓글이 없습니다.