TL;DR — 프레임마다 크기와 기준점이 다른 스프라이트를 다루기 위해 잘라낼 영역, 투명색, 피벗을 텍스트 데이터로 분리했습니다. 런타임에서는 애니메이션을 상체·하체·전체 동작으로 나누고, 스택을 이용해 이동 중 공격이나 방향 전환 같은 임시 동작이 끝나면 이전 동작으로 자연스럽게 돌아가도록 구성했습니다.
Table of contents
Open Table of contents
들어가며
이 프로젝트는 WinAPI와 GDI 기반 2D 게임 구조를 익히기 위해 약 2~3주 동안 진행한 메탈슬러그 모작입니다. 게임 루프, 오브젝트 관리, 충돌, 카메라 같은 기본 구조 위에 스프라이트 데이터 로딩과 캐릭터 애니메이션 상태 전환을 구현했습니다.
캐릭터 스프라이트를 화면에 표시하는 것 자체는 어렵지 않지만, 실제 동작을 연결하려면 두 가지 문제가 생겼습니다.
- 프레임마다 잘라낼 영역의 크기와 기준점이 달랐습니다.
- 달리면서 조준하거나 공격하는 것처럼 상체와 하체가 서로 다른 동작을 재생해야 했습니다.
이를 해결하기 위해 스프라이트 정보를 코드 밖의 데이터로 분리하고, 애니메이션을 상체·하체·전체 동작으로 나눠 조합했습니다. 이 글에서는 이 데이터가 파일에서 런타임으로 들어와 실제 캐릭터 동작으로 이어지는 흐름을 설명합니다.
화면 예시는 비상업적 학습용 모작에서 직접 만든 편집 도구를 보여주며, 포함된 원작 스프라이트의 권리는 각 권리자에게 있습니다.
사전 지식: 스프라이트 시트, 프레임 애니메이션, 상태 전환에 대한 기본 이해를 전제로 합니다.
1. 스프라이트 정보를 코드가 아닌 데이터로 분리했습니다
스프라이트 시트의 각 동작은 같은 간격의 격자로 배치되어 있지 않았습니다. 프레임마다 이미지의 위치와 크기가 달랐고, 캐릭터의 발이나 무기를 맞춰야 하는 기준점도 달랐습니다. 모든 값을 코드에 직접 입력하면 이미지를 수정할 때마다 소스를 다시 고쳐야 했습니다.
그래서 편집 과정에서 다음 정보를 애니메이션 파일로 저장했습니다.
slug_cannon_idle
[transparent]
153 217 234
[size]
3
10 1039 70 1095 30 56
75 1040 135 1095 30 55
140 1041 201 1095 30 54
프레임 한 줄은 다음 값을 의미합니다.
left top right bottom pivotX pivotY
left,top,right,bottom: 스프라이트 시트에서 가져올 사각 영역pivotX,pivotY: 해당 프레임을 오브젝트 위치에 맞출 기준점transparent: 복사하지 않을 배경색size: 애니메이션을 구성하는 프레임 수
![]()
프레임별 사각 영역과 피벗, 투명색을 저장하는 로컬 편집 도구
런타임의 FileManager는 파일을 읽어 AnimationInfo로 변환합니다. Animation::create()는 이 값을 다시 프레임 배열로 옮기므로, 렌더링 코드는 원본 이미지가 어떻게 배치되어 있는지 알 필요가 없습니다.
![]()
같은 동작에서도 크기가 다른 프레임을 개별 사각 영역으로 선택한 화면
스프라이트 시트
+-- 편집: 영역 / 투명색 / 피벗 지정
v
애니메이션 파일
+-- FileManager --> AnimationInfo
+-- Animation::Frame[]
`-- StateAnimator 분류
이 구조를 선택한 이유는 이미지 편집과 런타임 재생의 책임을 분리하기 위해서입니다. 프레임 구성이 달라져도 데이터만 다시 만들면 되고, 애니메이션 재생 코드는 그대로 사용할 수 있습니다.
2. 피벗으로 크기가 다른 프레임의 기준점을 맞췄습니다
각 프레임을 왼쪽 위 좌표나 이미지 중심에 맞춰 그리면 프레임 크기가 바뀔 때 캐릭터가 흔들려 보입니다. 예를 들어 달리기 동작에서는 몸의 폭이 계속 달라지지만, 발이 닿는 위치는 같은 지점에 유지되어야 합니다.
이를 위해 각 프레임에 피벗을 저장하고 다음 위치에 그렸습니다.
화면에 그릴 위치 = 오브젝트 위치 + 파트 오프셋 - 프레임 피벗
실제 렌더링에서는 프레임의 왼쪽 위와 오른쪽 아래로 너비·높이를 계산하고, 피벗만큼 이동한 목적지에 해당 영역을 복사합니다.
Vec2 lt = frames[renderFrame].leftTop;
Vec2 rb = frames[renderFrame].rb;
COLORREF transparent = frames[renderFrame].transparent;
Vec2 pivot = frames[renderFrame].pivot;
int width = rb.x - lt.x;
int height = rb.y - lt.y;
TransparentBlt(dc,
offset.x - pivot.x,
offset.y - pivot.y,
width,
height,
texture->getTextureDC(),
lt.x,
lt.y,
width,
height,
transparent);Animation.cpp
TransparentBlt에는 원본 사각형과 함께 투명색을 전달했습니다. 이 색과 일치하는 배경 픽셀은 복사하지 않으므로 잘라낸 캐릭터만 백 버퍼 위에 그릴 수 있습니다.
고정된 이미지 중심 대신 프레임별 피벗을 사용한 이유는 이미지 크기와 캐릭터의 기준점을 분리하기 위해서입니다. 덕분에 프레임의 잘라내기 영역이 달라도 발, 몸통, 무기처럼 화면에서 유지해야 할 위치를 같은 좌표에 맞출 수 있었습니다.
3. 상체·하체와 전체 애니메이션을 나눴습니다
플레이어는 달리는 동안에도 정면이나 위쪽을 조준할 수 있습니다. 캐릭터 전체를 하나의 애니메이션으로만 관리하면 달리기 + 정면 공격, 달리기 + 위쪽 공격처럼 가능한 조합마다 별도의 전체 프레임이 필요합니다.
이를 줄이기 위해 애니메이션을 다음 단위로 분리했습니다.
이동 + 조준/공격
+-- upper : 상체의 조준과 공격
`-- legs : 하체의 대기와 이동
사망 / 탑승 / 방향 전환
`-- whole : 캐릭터 전체를 사용하는 동작
StateAnimator::init()은 애니메이션 이름에 포함된 upper, legs, whole, weapon을 기준으로 각 Animator에 데이터를 등록합니다. 작은 프로젝트에서 별도의 설정 형식을 더 만들기보다, 편집 단계의 이름 규칙을 런타임 분류 규칙으로 그대로 사용한 것입니다.
상체와 하체 애니메이션은 함께 갱신하고 렌더링하지만, 전체 동작이 재생 중이면 다른 파트를 잠시 중단했습니다.
void StateAnimator::renderAnimators(GameGraphics& g)
{
if (!animators[ANIMATION_TYPE::WHOLE]->getAnimStack().empty())
{
animators[ANIMATION_TYPE::WHOLE]->renderAnimationStack(g);
return;
}
for (UINT i = 0; i < (UINT)ANIMATION_TYPE::END - 1; i++)
{
animators[(ANIMATION_TYPE)i]->renderAnimationStack(g);
}
}StateAnimator.cpp
평소에는 상체와 하체를 조합해 이동과 공격을 독립적으로 표현하고, 사망이나 탑승처럼 몸 전체의 자세가 바뀌는 순간에는 whole이 우선권을 갖습니다. 모든 동작을 하나의 상태로 늘어놓는 대신, 조합 가능한 동작과 배타적인 동작을 구분한 것이 핵심입니다.
4. 애니메이션 스택으로 임시 동작을 복귀시켰습니다
상태를 나누더라도 공격이나 방향 전환이 끝난 뒤 어떤 동작으로 돌아갈지 결정해야 합니다. 이를 위해 각 Animator는 현재 재생할 애니메이션을 스택으로 관리했습니다.
[공격 1회] <- 현재 재생
[달리기 반복]
[기본 대기 반복] <- 기반 동작
대기나 달리기처럼 계속 이어지는 동작은 반복 상태로 쌓고, 공격이나 방향 전환은 한 번만 재생하도록 위에 추가했습니다. 매 프레임에는 스택의 맨 위 애니메이션만 갱신하고 그립니다.
top.first->update();
if (top.first->isFinish())
{
if (!top.second)
{
if (!top.first->isPause())
{
animationStack.pop();
}
}
else
{
if (top.first->isHold())
{
top.second = false;
top.first->setPause(true);
top.first->setTargetFrame(top.first->getHoldFrame());
}
else
{
top.first->resetAnimation();
}
}
}Animator.cpp
일회성 애니메이션이 끝나면 스택에서 제거되므로 바로 아래의 달리기나 대기 동작이 다시 화면에 나타납니다. 공격이 끝날 때마다 이전 상태를 별도로 저장하고 복구하는 대신, 스택 자체가 복귀할 순서를 보존합니다.
StateController와 ObjStateMachine은 입력에 따라 어떤 동작을 추가할지 결정하고, Animator는 재생과 반복, 종료 후 복귀를 담당했습니다. 이렇게 게임 상태의 판단과 프레임 재생을 분리해 같은 애니메이션 관리 방식을 플레이어와 탑승물에 함께 사용할 수 있었습니다.
5. 입력에서 화면까지 하나의 흐름으로 연결했습니다
달리면서 위쪽으로 공격하는 상황은 다음 순서로 처리됩니다.
StateController가 이동과 공격 입력을 현재 상태 머신에 전달합니다.- 하체
Animator에는 반복되는 달리기 애니메이션이 유지됩니다. - 상체
Animator에는 위쪽 공격 애니메이션이 일회성으로 추가됩니다. - 각
Animator가 스택의 맨 위 프레임을 선택합니다. Animation이 프레임 영역과 피벗을 이용해 백 버퍼에 스프라이트를 그립니다.- 공격이 끝나면 상체 스택에서 제거되고 이전 조준 또는 이동 자세로 돌아갑니다.
입력
-> 상태 전환
-> upper / legs / whole 스택 선택
-> 현재 프레임 갱신
-> 프레임 영역 + 피벗 계산
-> TransparentBlt
-> 화면 출력
파일에 저장한 작은 프레임 정보가 런타임의 상태 전환과 렌더링까지 같은 흐름으로 이어집니다. 데이터 분리는 스프라이트 수정 비용을 줄였고, 파트 분리와 스택은 이동·조준·공격을 필요한 단위로 조합할 수 있게 했습니다.
정리하며
핵심 요약:
- 프레임 영역, 투명색, 피벗을 파일로 분리해 스프라이트 구성을 데이터로 관리했습니다.
- 프레임별 피벗을 사용해 크기가 다른 이미지도 같은 캐릭터 기준점에 맞췄습니다.
- 상체와 하체는 조합하고, 전체 자세가 필요한 동작은 우선 재생하도록 구분했습니다.
- 애니메이션 스택으로 일회성 동작이 끝난 뒤 이전 반복 동작으로 복귀시켰습니다.
- 상태 판단과 애니메이션 재생을 나눠 입력부터 화면 출력까지의 흐름을 단순하게 유지했습니다.
이 프로젝트에서는 화려한 렌더링 기능보다, 불규칙한 스프라이트 데이터를 런타임에서 사용할 수 있는 형태로 바꾸고 캐릭터 상태와 연결하는 데 집중했습니다. 이미지 데이터, 상태 전환, 렌더링의 책임을 나눠 기본적인 2D 클라이언트 구조를 직접 구성해 본 경험이었습니다.
참고 자료
- Microsoft Learn — TransparentBlt 함수 — 원본·목적지 사각형과 투명색을 사용하는
GDI비트맵 복사 API를 확인했습니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.