본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 29. · 4 Views
게임 루프와 컴포넌트 완벽 가이드
Flame 게임 엔진의 핵심인 게임 루프와 컴포넌트 시스템을 이해하고, 실전에서 활용하는 방법을 배웁니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 예제와 함께 설명합니다.
목차
1. 게임 루프 개념
입사 2개월 차 신입 개발자 김개발 씨가 처음으로 모바일 게임 프로젝트에 투입되었습니다. "게임은 웹이랑 다르게 계속 움직이는데, 어떻게 구현하는 거예요?" 선배 개발자 박시니어 씨가 웃으며 대답했습니다.
"게임 루프라는 게 있어요. 이게 게임의 심장이죠."
게임 루프는 게임이 실행되는 동안 끊임없이 반복되는 순환 구조입니다. 마치 심장이 쉬지 않고 뛰는 것처럼, 게임 루프는 화면을 갱신하고 입력을 처리하며 게임 상태를 업데이트합니다.
일반적으로 초당 60회 정도 반복되며, 이를 통해 부드러운 애니메이션과 실시간 반응이 가능해집니다.
다음 코드를 살펴봅시다.
import 'package:flame/game.dart';
class MyGame extends FlameGame {
@override
void update(double dt) {
super.update(dt);
// dt는 delta time, 이전 프레임 이후 경과 시간(초)
// 여기서 게임 로직을 업데이트합니다
print('Update: ${dt}초 경과');
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 화면에 그래픽을 그립니다
print('Render 호출됨');
}
}
김개발 씨는 지금까지 웹 개발만 해왔습니다. 버튼을 클릭하면 이벤트가 발생하고, 그때 화면이 업데이트되는 방식에 익숙했죠.
하지만 게임은 달랐습니다. 아무것도 하지 않아도 캐릭터가 움직이고, 배경이 스크롤되고, 적이 나타났습니다.
"도대체 어떻게 이런 게 가능한 거죠?" 김개발 씨가 궁금해하며 물었습니다. 박시니어 씨가 화이트보드에 원을 그리며 설명을 시작했습니다.
"게임 루프라는 개념이 있어요. 이게 게임의 핵심이죠." 게임 루프란 정확히 무엇일까요?
쉽게 비유하자면, 게임 루프는 마치 영화 필름과 같습니다. 영화는 사실 수많은 정지된 사진을 빠르게 연속으로 보여주는 것이죠.
보통 초당 24프레임입니다. 게임도 마찬가지입니다.
초당 60번 정도 화면을 새로 그리면서 움직이는 것처럼 보이게 만듭니다. 게임 루프가 없던 시절에는 어땠을까요?
초창기 게임들은 프레임마다 직접 타이밍을 계산해야 했습니다. 개발자가 일일이 "지금 화면을 그려라", "이제 입력을 받아라", "상태를 업데이트해라"를 직접 제어했습니다.
코드가 복잡해지고, 컴퓨터 성능에 따라 게임 속도가 달라지는 문제도 있었습니다. 더 큰 문제는 유지보수였습니다.
게임 로직이 여기저기 흩어져 있어서 버그를 찾기도 어려웠습니다. 바로 이런 문제를 해결하기 위해 게임 루프 패턴이 등장했습니다.
게임 루프를 사용하면 일정한 간격으로 게임 상태를 업데이트할 수 있습니다. 또한 컴퓨터 성능에 관계없이 일관된 게임 속도를 유지할 수 있습니다.
무엇보다 코드가 명확하게 분리되어 유지보수가 쉬워진다는 큰 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 FlameGame을 상속받는 부분을 보면 Flame 엔진의 게임 루프를 사용하겠다는 의미입니다. 이것이 핵심입니다.
다음으로 update 메서드에서는 게임 로직 업데이트가 일어납니다. 매개변수 dt는 delta time의 약자로, 이전 프레임 이후 경과한 시간을 초 단위로 나타냅니다.
마지막으로 render 메서드에서 화면에 그래픽이 그려집니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 러닝 게임을 개발한다고 가정해봅시다. 캐릭터가 계속 앞으로 달리고, 장애물이 나타나고, 점수가 올라가는 상황에서 게임 루프를 활용하면 모든 게임 오브젝트를 일관되게 업데이트하고 렌더링할 수 있습니다.
많은 게임 회사에서 이런 패턴을 기본으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 update 메서드 안에 무거운 연산을 넣는 것입니다. 이렇게 하면 프레임이 떨어지고 게임이 버벅거리는 문제가 발생할 수 있습니다.
따라서 복잡한 계산은 별도 스레드에서 처리하거나 여러 프레임에 걸쳐 나누어 수행해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 게임이 계속 움직이는 거였군요!" 게임 루프를 제대로 이해하면 게임의 동작 원리를 명확하게 파악할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - dt(delta time)를 활용하면 컴퓨터 성능에 관계없이 일정한 속도로 게임을 진행할 수 있습니다
- update는 로직만, render는 그리기만 담당하도록 명확히 분리하세요
2. update와 render 메서드
김개발 씨가 첫 게임 코드를 작성하던 중 궁금증이 생겼습니다. "update랑 render가 계속 나오는데, 이 둘의 차이가 뭐죠?" 박시니어 씨가 커피를 한 모금 마시며 답했습니다.
"그게 게임 프로그래밍의 핵심이에요. 분리의 미학이죠."
update 메서드는 게임의 상태와 로직을 처리하는 부분입니다. render 메서드는 화면에 그래픽을 그리는 부분입니다.
이 둘을 분리하면 게임 로직과 화면 출력을 독립적으로 관리할 수 있어, 코드가 깔끔해지고 성능 최적화도 쉬워집니다. 마치 요리사가 조리와 플레이팅을 나누어 진행하는 것과 같습니다.
다음 코드를 살펴봅시다.
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class MyGame extends FlameGame {
double playerX = 0;
double playerY = 0;
double speed = 100; // 초당 100픽셀 이동
@override
void update(double dt) {
super.update(dt);
// 게임 로직: 플레이어 위치 업데이트
playerX += speed * dt;
// 화면 끝에 도달하면 반대편으로
if (playerX > size.x) playerX = 0;
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 그리기: 플레이어를 원으로 표시
canvas.drawCircle(
Offset(playerX, playerY),
20,
Paint()..color = Colors.blue,
);
}
}
김개발 씨는 코드를 작성하다가 혼란스러워졌습니다. 플레이어의 위치를 계산하는 코드를 update에 넣어야 할지, render에 넣어야 할지 확실하지 않았습니다.
둘 다 매 프레임마다 호출되는 건 마찬가지인데, 왜 굳이 나눠야 하는 걸까요? 박시니어 씨가 웃으며 말했습니다.
"처음엔 다들 그래요. 하지만 이 구분이 정말 중요해요." update와 render를 분리하는 이유는 무엇일까요?
쉽게 비유하자면, 이 둘의 관계는 마치 요리사와 플레이팅의 관계와 같습니다. 요리사는 먼저 음식을 만듭니다.
재료를 손질하고, 불을 조절하고, 맛을 봅니다. 그런 다음 완성된 음식을 아름답게 접시에 담습니다.
만약 요리하면서 동시에 플레이팅을 하려고 하면 어떻게 될까요? 매우 복잡하고 실수하기 쉬울 것입니다.
게임도 마찬가지입니다. update에서는 게임의 상태를 계산하고, render에서는 그 결과를 화면에 표시합니다.
이렇게 분리하지 않던 시절에는 어땠을까요? 초기 게임 프로그래밍에서는 계산과 그리기가 뒤섞여 있었습니다.
적의 위치를 계산하면서 바로 화면에 그리고, 점수를 업데이트하면서 바로 텍스트를 출력했습니다. 코드가 복잡해지고, 어디서 문제가 생겼는지 찾기 어려웠습니다.
더 큰 문제는 성능이었습니다. 그리기 작업은 상대적으로 무겁기 때문에, 로직과 섞이면 최적화가 어려웠습니다.
바로 이런 문제를 해결하기 위해 update와 render의 분리 패턴이 등장했습니다. update와 render를 분리하면 각각의 역할이 명확해집니다.
또한 성능 프로파일링도 쉬워집니다. 게임이 느리다면 update가 문제인지, render가 문제인지 바로 알 수 있습니다.
무엇보다 코드의 가독성이 크게 향상된다는 이점이 있습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 update 메서드 안을 보면 플레이어의 x 좌표를 업데이트하는 로직이 있습니다. speed * dt를 더하는 부분이 핵심입니다.
dt는 이전 프레임 이후 경과 시간이므로, 컴퓨터 성능에 관계없이 일정한 속도로 이동하게 됩니다. 화면 끝에 도달하면 반대편으로 돌아가는 로직도 update에 있습니다.
이것은 게임 규칙이기 때문입니다. 다음으로 render 메서드를 보면 실제로 화면에 무언가를 그리는 코드만 있습니다.
canvas.drawCircle로 파란색 원을 그립니다. 여기서는 계산이나 로직 처리를 하지 않습니다.
단순히 현재 상태를 시각화할 뿐입니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 슈팅 게임을 개발한다고 가정해봅시다. update에서는 총알의 위치를 계산하고, 적과의 충돌을 체크하고, 점수를 업데이트합니다.
render에서는 총알 스프라이트를 그리고, 적 캐릭터를 그리고, 점수 UI를 표시합니다. 이렇게 분리하면 나중에 그래픽만 바꾸고 싶을 때 render만 수정하면 됩니다.
게임 난이도를 조절하고 싶을 때는 update만 수정하면 됩니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 render 안에서 게임 상태를 변경하는 것입니다. 예를 들어 "적을 그리면서 적의 체력도 감소시킨다"와 같은 코드를 작성하면 안 됩니다.
이렇게 하면 나중에 렌더링 최적화를 할 때 게임 로직이 깨질 수 있습니다. 따라서 상태 변경은 반드시 update에서만 해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다.
"아, 그래서 update에는 계산만, render에는 그리기만 넣는 거군요!" update와 render를 제대로 분리하면 깔끔하고 유지보수하기 쉬운 게임 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - update에서는 절대 그리기 작업을 하지 마세요
- render에서는 절대 게임 상태를 변경하지 마세요
- dt를 활용하여 프레임 독립적인 게임을 만드세요
3. FlameGame 클래스
김개발 씨가 게임 프로젝트를 시작하려고 하는데, 어디서부터 시작해야 할지 막막했습니다. "게임 루프도 직접 만들어야 하나요?" 박시니어 씨가 고개를 저었습니다.
"아니요, FlameGame 클래스를 쓰면 됩니다. 이미 다 준비되어 있어요."
FlameGame은 Flame 엔진의 핵심 클래스로, 게임 루프, 컴포넌트 관리, 입력 처리 등 게임 개발에 필요한 모든 기본 기능을 제공합니다. 이 클래스를 상속받기만 하면 복잡한 초기 설정 없이 바로 게임 개발을 시작할 수 있습니다.
마치 요리할 때 이미 준비된 밀키트를 사용하는 것과 같습니다.
다음 코드를 살펴봅시다.
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
// 게임 초기화: 리소스 로딩, 초기 설정
await super.onLoad();
print('게임이 로드되었습니다');
}
@override
void update(double dt) {
super.update(dt);
// 게임 로직 업데이트
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 화면 렌더링
}
@override
void onRemove() {
// 게임 종료 시 정리 작업
print('게임이 종료됩니다');
super.onRemove();
}
}
김개발 씨는 게임 개발이 처음이었습니다. "게임 루프를 직접 만들어야 하나?
입력은 어떻게 받지? 화면 크기는 어떻게 알아내지?" 질문이 꼬리에 꼬리를 물었습니다.
웹 개발과는 너무 달라서 어디서부터 손을 대야 할지 막막했습니다. 박시니어 씨가 모니터 앞으로 다가왔습니다.
"걱정 마세요. FlameGame이라는 게 있어요." FlameGame이란 정확히 무엇일까요?
쉽게 비유하자면, FlameGame은 마치 요리 밀키트와 같습니다. 밀키트에는 손질된 재료, 소스, 레시피가 모두 들어있어서 초보자도 쉽게 요리할 수 있습니다.
굳이 처음부터 재료를 사러 가고, 손질하고, 레시피를 찾을 필요가 없죠. FlameGame도 마찬가지입니다.
게임 개발에 필요한 기본 기능이 모두 준비되어 있어서, 개발자는 게임의 고유한 로직에만 집중할 수 있습니다. FlameGame이 없던 시절에는 어땠을까요?
개발자들은 게임 루프를 직접 구현해야 했습니다. 타이머를 설정하고, 프레임 레이트를 계산하고, 입력 이벤트를 처리하는 코드를 모두 작성해야 했습니다.
수백 줄의 보일러플레이트 코드가 필요했죠. 더 큰 문제는 버그였습니다.
게임 루프가 제대로 작동하지 않으면 전체 게임이 망가졌습니다. 새 프로젝트를 시작할 때마다 이 모든 코드를 다시 작성하거나 복사해야 했습니다.
바로 이런 문제를 해결하기 위해 FlameGame 클래스가 등장했습니다. FlameGame을 사용하면 게임 루프가 자동으로 동작합니다.
또한 컴포넌트 시스템, 충돌 감지, 입력 처리 등 필수 기능이 모두 내장되어 있습니다. 무엇보다 Flutter 위젯과 완벽하게 통합되어 Flutter의 강력한 UI 기능을 함께 사용할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 extends FlameGame 부분을 보면 FlameGame의 모든 기능을 상속받겠다는 의미입니다.
이것이 핵심입니다. 다음으로 onLoad 메서드에서는 게임 초기화가 일어납니다.
이미지, 사운드 등 리소스를 로딩하는 작업을 여기서 합니다. async/await를 사용할 수 있어서 비동기 작업도 쉽게 처리됩니다.
update와 render는 이미 배운 내용입니다. FlameGame이 이 메서드들을 자동으로 호출해 주기 때문에, 우리는 오버라이드해서 구현만 하면 됩니다.
마지막으로 onRemove는 게임이 종료될 때 호출됩니다. 여기서 리소스를 정리하고 메모리를 해제합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 퍼즐 게임을 개발한다고 가정해봅시다.
FlameGame을 상속받은 클래스에서 onLoad에서는 퍼즐 조각 이미지를 로딩하고, update에서는 사용자의 드래그 입력에 따라 조각을 이동시키고, render에서는 퍼즐 보드를 그립니다. FlameGame이 기본적인 게임 흐름을 관리해 주기 때문에, 개발자는 퍼즐 로직에만 집중할 수 있습니다.
많은 인디 게임 개발자들이 이런 방식으로 빠르게 프로토타입을 만들고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 FlameGame을 상속받은 클래스에 너무 많은 로직을 넣는 것입니다. 게임이 커지면 하나의 클래스가 수천 줄이 되어 관리하기 어려워집니다.
따라서 게임의 각 부분을 컴포넌트로 분리하여 관리해야 합니다. FlameGame 클래스는 전체를 조율하는 지휘자 역할만 하도록 설계하는 것이 좋습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 안도의 한숨을 쉬었습니다.
"아, 직접 다 만들 필요가 없었군요!" FlameGame을 제대로 활용하면 빠르고 효율적으로 게임을 개발할 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - onLoad는 async이므로 이미지, 사운드 로딩 등 비동기 작업에 적합합니다
- FlameGame 클래스는 가볍게 유지하고, 실제 로직은 컴포넌트로 분리하세요
4. Component 기초
김개발 씨가 게임에 플레이어, 적, 아이템을 추가하려다가 막혔습니다. "이걸 다 FlameGame 클래스에 넣으면 너무 복잡해질 것 같은데요?" 박시니어 씨가 웃으며 답했습니다.
"그래서 Component라는 게 있어요. 레고 블록처럼 조립하는 거죠."
Component는 게임을 구성하는 독립적인 단위입니다. 플레이어, 적, 총알, UI 등 게임의 각 요소를 컴포넌트로 만들어 관리합니다.
각 컴포넌트는 자신만의 update와 render를 가질 수 있어서, 코드를 깔끔하게 분리할 수 있습니다. 마치 레고 블록을 조립하듯이 컴포넌트를 조합하여 복잡한 게임을 만들 수 있습니다.
다음 코드를 살펴봅시다.
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
// 커스텀 컴포넌트 정의
class Player extends Component {
@override
Future<void> onLoad() async {
await super.onLoad();
print('플레이어 컴포넌트 로드됨');
}
@override
void update(double dt) {
super.update(dt);
// 플레이어 로직 업데이트
}
@override
void render(Canvas canvas) {
super.render(canvas);
// 플레이어 그리기
canvas.drawCircle(Offset.zero, 20, Paint()..color = Colors.green);
}
}
class MyGame extends FlameGame {
@override
Future<void> onLoad() async {
await super.onLoad();
// 플레이어 컴포넌트를 게임에 추가
await add(Player());
}
}
김개발 씨는 게임에 기능을 추가할수록 코드가 점점 복잡해지는 것을 느꼈습니다. FlameGame 클래스의 update 메서드에 플레이어 로직, 적 로직, 총알 로직이 모두 섞여 있었습니다.
render 메서드도 마찬가지였습니다. 수백 줄이 넘어가면서 어디서부터 손을 대야 할지 막막했습니다.
"이거 정말 정상적인 방법인가요?" 김개발 씨가 불안해하며 물었습니다. 박시니어 씨가 고개를 저었습니다.
"아니요, 컴포넌트 시스템을 사용해야 해요." 컴포넌트 시스템이란 정확히 무엇일까요? 쉽게 비유하자면, 컴포넌트는 마치 레고 블록과 같습니다.
레고로 집을 만들 때 벽돌 블록, 창문 블록, 지붕 블록을 따로 만들어서 조립하죠. 전부 하나의 덩어리로 만들지 않습니다.
게임도 마찬가지입니다. 플레이어 컴포넌트, 적 컴포넌트, 총알 컴포넌트를 각각 만들어서 조합합니다.
이렇게 하면 코드가 명확하게 분리되고, 재사용하기도 쉽습니다. 컴포넌트 시스템이 없던 시절에는 어땠을까요?
과거 게임들은 모든 코드가 하나의 거대한 파일에 섞여 있었습니다. 플레이어를 업데이트하는 코드, 적을 업데이트하는 코드, 총알을 업데이트하는 코드가 모두 한 곳에 있었죠.
버그가 생기면 어디서 문제가 생긴 건지 찾기 어려웠습니다. 더 큰 문제는 재사용이었습니다.
다른 게임에서 비슷한 적 캐릭터를 만들고 싶어도, 코드가 섞여 있어서 복사하기 어려웠습니다. 바로 이런 문제를 해결하기 위해 컴포넌트 패턴이 등장했습니다.
컴포넌트를 사용하면 각 게임 요소가 독립적으로 동작합니다. 또한 컴포넌트는 다른 프로젝트에서도 재사용할 수 있습니다.
무엇보다 팀 작업이 쉬워진다는 큰 이점이 있습니다. A 개발자는 플레이어 컴포넌트를, B 개발자는 적 컴포넌트를 각각 개발할 수 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 Player extends Component 부분을 보면 새로운 컴포넌트를 정의하고 있습니다.
이것이 핵심입니다. 플레이어라는 독립적인 게임 요소를 만드는 것이죠.
Component 클래스는 onLoad, update, render 메서드를 가지고 있어서, FlameGame과 비슷한 구조입니다. onLoad 메서드에서는 플레이어 컴포넌트만의 초기화를 합니다.
플레이어 이미지를 로딩하거나, 초기 위치를 설정하는 등의 작업을 여기서 합니다. update와 render도 플레이어만의 로직을 담당합니다.
전체 게임이 아니라 이 컴포넌트만의 업데이트와 렌더링입니다. 마지막으로 add(Player()) 부분을 보면 게임에 컴포넌트를 추가하고 있습니다.
이렇게 추가하면 FlameGame이 자동으로 이 컴포넌트의 update와 render를 호출해 줍니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 타워 디펜스 게임을 개발한다고 가정해봅시다. Tower 컴포넌트, Enemy 컴포넌트, Projectile 컴포넌트를 각각 만듭니다.
Tower 컴포넌트는 자신의 사거리 안에 적이 들어오는지 감지하고, Projectile을 발사합니다. Enemy 컴포넌트는 정해진 경로를 따라 이동합니다.
각 컴포넌트가 독립적으로 동작하면서도 서로 상호작용합니다. 새로운 타워를 추가하고 싶으면 새 Tower 컴포넌트를 만들기만 하면 됩니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 컴포넌트 간에 직접적인 참조를 남발하는 것입니다.
예를 들어 Enemy 컴포넌트가 Player 컴포넌트를 직접 찾아서 거리를 계산하는 식입니다. 이렇게 하면 컴포넌트끼리 강하게 결합되어 재사용이 어려워집니다.
따라서 이벤트 시스템이나 게임 매니저를 통해 느슨하게 연결하는 것이 좋습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 눈이 반짝였습니다. "아, 각 요소를 독립적으로 만드는 거군요!" 컴포넌트 시스템을 제대로 활용하면 깔끔하고 확장 가능한 게임 구조를 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 한 컴포넌트가 500줄을 넘어가면 더 작은 컴포넌트로 분리하는 것을 고려하세요
- 컴포넌트는 가능한 한 독립적으로 만들어서 재사용성을 높이세요
5. PositionComponent 사용
김개발 씨가 플레이어를 화면에 배치하려다가 또 막혔습니다. "위치랑 크기를 어떻게 관리하죠?
회전도 시켜야 하는데..." 박시니어 씨가 코드를 가리키며 말했습니다. "PositionComponent를 쓰면 이런 건 자동으로 처리돼요."
PositionComponent는 위치, 크기, 각도, 앵커 등 공간적 속성을 가진 컴포넌트입니다. 대부분의 게임 오브젝트는 화면상의 위치와 크기를 가지므로, PositionComponent를 상속받아 만듭니다.
이 클래스는 이동, 회전, 크기 조절 등의 기능을 기본으로 제공하여 개발자가 직접 구현할 필요가 없게 해줍니다.
다음 코드를 살펴봅시다.
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class Player extends PositionComponent {
@override
Future<void> onLoad() async {
await super.onLoad();
// 위치, 크기, 앵커 설정
position = Vector2(100, 100); // x=100, y=100
size = Vector2(50, 50); // 너비=50, 높이=50
anchor = Anchor.center; // 중심점을 기준으로
}
@override
void update(double dt) {
super.update(dt);
// 오른쪽으로 이동
position.x += 50 * dt;
// 회전 (라디안 단위)
angle += 1 * dt;
}
@override
void render(Canvas canvas) {
super.render(canvas);
// size를 사용하여 사각형 그리기
canvas.drawRect(size.toRect(), Paint()..color = Colors.red);
}
}
김개발 씨는 플레이어 캐릭터를 만들면서 또 다른 벽에 부딪혔습니다. 위치를 나타내는 변수, 크기를 나타내는 변수, 회전 각도를 나타내는 변수를 일일이 선언해야 했습니다.
거기에 앵커 포인트, 스케일, 부모-자식 관계까지 고려하니 머리가 복잡해졌습니다. "이것도 다 직접 만들어야 하나요?" 김개발 씨가 지친 목소리로 물었습니다.
박시니어 씨가 고개를 저었습니다. "아니요, PositionComponent가 다 제공해요." PositionComponent란 정확히 무엇일까요?
쉽게 비유하자면, PositionComponent는 마치 가구 조립 키트의 기본 프레임과 같습니다. 책상을 조립할 때 상판의 위치, 다리의 높이, 전체 크기 등을 측정하고 관리하는 기본 구조가 있습니다.
여기에 서랍, 선반 등 추가 기능을 붙이는 거죠. PositionComponent도 마찬가지입니다.
위치, 크기, 회전 같은 기본적인 공간 속성을 제공하고, 개발자는 그 위에 게임만의 로직을 추가하면 됩니다. PositionComponent가 없던 시절에는 어땠을까요?
개발자들은 각 게임 오브젝트마다 x, y, width, height, angle 같은 변수를 직접 선언했습니다. 이동하는 로직도 직접 구현했고, 회전 행렬도 직접 계산했습니다.
자식 오브젝트가 부모를 따라 움직이게 하려면 상대 좌표를 계산하는 복잡한 수학도 필요했습니다. 더 큰 문제는 일관성이었습니다.
A 개발자는 왼쪽 위를 기준점으로, B 개발자는 중심을 기준점으로 만들면 나중에 합치기 어려웠습니다. 바로 이런 문제를 해결하기 위해 PositionComponent가 등장했습니다.
PositionComponent를 사용하면 공간 속성 관리가 자동화됩니다. 또한 부모-자식 계층 구조를 쉽게 만들 수 있습니다.
예를 들어 우주선에 포탑을 붙이면, 우주선이 회전할 때 포탑도 자동으로 따라 회전합니다. 무엇보다 Flame 엔진의 다른 기능들과 완벽하게 통합되어 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 extends PositionComponent 부분을 보면 공간 속성을 가진 컴포넌트를 만들겠다는 의미입니다.
이것이 핵심입니다. 일반 Component 대신 PositionComponent를 상속받으면 position, size, angle 같은 속성을 바로 사용할 수 있습니다.
position = Vector2(100, 100) 부분에서는 화면상의 위치를 설정합니다. Vector2는 2차원 벡터로, x와 y 좌표를 담고 있습니다.
**size = Vector2(50, 50)**는 컴포넌트의 크기입니다. anchor = Anchor.center는 기준점을 중심으로 설정한다는 의미입니다.
이렇게 하면 회전할 때 중심을 기준으로 돌아갑니다. update 메서드에서 position.x += 50 * dt를 보면 위치를 직접 변경하고 있습니다.
PositionComponent가 제공하는 position 속성을 그냥 수정하기만 하면 됩니다. angle += 1 * dt는 회전입니다.
각도는 라디안 단위입니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 횡스크롤 슈팅 게임을 개발한다고 가정해봅시다. 플레이어 우주선을 PositionComponent로 만들고, 그 위에 좌우 날개를 자식 컴포넌트로 추가합니다.
우주선이 기울어지면 날개도 자동으로 따라 기울어집니다. 또한 적 캐릭터도 PositionComponent로 만들어서 화면 밖으로 나갔는지 쉽게 확인할 수 있습니다.
충돌 감지도 position과 size를 활용하면 간단하게 구현됩니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 anchor를 잘못 설정하는 것입니다. 기본값은 Anchor.topLeft인데, 회전이나 스케일을 적용하면 예상과 다르게 동작할 수 있습니다.
대부분의 경우 Anchor.center를 사용하는 것이 직관적입니다. 따라서 컴포넌트를 만들 때 앵커를 명시적으로 설정하는 습관을 들이는 것이 좋습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 감탄했습니다.
"와, 이렇게 간단하게 위치랑 회전을 다룰 수 있다니!" PositionComponent를 제대로 활용하면 공간 관련 코드를 크게 줄이고 직관적으로 만들 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 대부분의 게임 오브젝트는 PositionComponent를 상속받아 만드세요
- anchor는 명시적으로 설정하는 것이 좋습니다 (보통 Anchor.center)
- Vector2를 활용하면 벡터 연산이 간편해집니다
6. 컴포넌트 라이프사이클
김개발 씨가 컴포넌트를 만들어서 게임에 추가했는데, 언제 초기화되고 언제 정리되는지 헷갈렸습니다. "컴포넌트가 생성되고 사라지는 과정이 어떻게 되나요?" 박시니어 씨가 화이트보드에 흐름도를 그리기 시작했습니다.
"라이프사이클이라는 게 있어요."
컴포넌트 라이프사이클은 컴포넌트가 생성되고, 로드되고, 업데이트되고, 제거되는 전체 과정을 의미합니다. onLoad에서 초기화하고, onMount에서 게임 트리에 추가되고, update/render로 동작하며, onRemove에서 정리됩니다.
이 흐름을 이해하면 리소스 관리와 메모리 누수를 방지할 수 있습니다.
다음 코드를 살펴봅시다.
import 'package:flame/components.dart';
import 'package:flame/game.dart';
class Enemy extends PositionComponent {
@override
Future<void> onLoad() async {
await super.onLoad();
print('1. onLoad: 리소스 로딩, 초기 설정');
// 이미지 로딩, 초기 위치/크기 설정
position = Vector2(200, 200);
size = Vector2(40, 40);
}
@override
void onMount() {
super.onMount();
print('2. onMount: 게임 트리에 추가됨');
// 부모 컴포넌트에 접근 가능
}
@override
void update(double dt) {
super.update(dt);
// 3. update: 매 프레임 호출
position.y += 100 * dt; // 아래로 이동
}
@override
void onRemove() {
print('4. onRemove: 정리 작업');
// 리소스 해제, 이벤트 리스너 제거
super.onRemove();
}
}
김개발 씨는 적 캐릭터를 만들었는데, 게임에서 사라진 후에도 메모리를 계속 차지하는 문제가 생겼습니다. 적을 제거했는데도 update가 계속 호출되는 것 같았습니다.
심지어 가끔 앱이 느려지거나 크래시가 발생했습니다. "뭐가 문제죠?
분명히 remove를 호출했는데..." 김개발 씨가 당황했습니다. 박시니어 씨가 코드를 살펴보더니 말했습니다.
"라이프사이클을 제대로 이해하지 못해서 생긴 문제예요." 컴포넌트 라이프사이클이란 정확히 무엇일까요? 쉽게 비유하자면, 라이프사이클은 마치 사람의 일생과 같습니다.
태어나서(생성), 학교에 입학하고(마운트), 매일 생활하며(업데이트), 언젠가 은퇴합니다(제거). 각 단계마다 해야 할 일이 다릅니다.
태어날 때는 이름을 짓고, 입학할 때는 가방을 준비하고, 생활하면서는 공부하고, 은퇴할 때는 정리하죠. 컴포넌트도 마찬가지입니다.
각 단계마다 적절한 작업을 해야 합니다. 라이프사이클 개념이 없던 시절에는 어땠을까요?
개발자들은 언제 초기화를 해야 할지, 언제 정리를 해야 할지 명확하지 않았습니다. 생성자에서 이미지를 로딩하려다 에러가 나고, 제거할 때 리소스를 해제하는 걸 잊어서 메모리 누수가 생겼습니다.
특히 비동기 작업을 다룰 때 문제가 심각했습니다. 이미지가 로딩되기 전에 컴포넌트가 화면에 나타나서 깨진 화면이 보이기도 했습니다.
바로 이런 문제를 해결하기 위해 명확한 라이프사이클 메서드가 등장했습니다. 라이프사이클을 이해하면 적절한 타이밍에 적절한 작업을 할 수 있습니다.
또한 메모리 누수를 방지하고 성능을 최적화할 수 있습니다. 무엇보다 비동기 작업을 안전하게 처리할 수 있다는 큰 이점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 onLoad 메서드를 보면 async/await를 사용할 수 있습니다.
이것이 핵심입니다. 이미지나 사운드 같은 리소스를 로딩하는 비동기 작업을 여기서 합니다.
onLoad가 완료될 때까지 컴포넌트는 화면에 나타나지 않습니다. 따라서 안전하게 초기화할 수 있습니다.
onMount 메서드는 컴포넌트가 게임 트리에 추가된 직후 호출됩니다. 이때부터 parent, game 같은 속성에 접근할 수 있습니다.
부모 컴포넌트의 정보가 필요한 초기화는 여기서 해야 합니다. update와 render는 컴포넌트가 활성 상태일 때 매 프레임마다 호출됩니다.
게임의 대부분의 시간을 여기서 보냅니다. 마지막으로 onRemove는 컴포넌트가 게임에서 제거될 때 호출됩니다.
여기서 타이머를 취소하고, 이벤트 리스너를 제거하고, 리소스를 해제합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 보스 전투를 개발한다고 가정해봅시다. Boss 컴포넌트의 onLoad에서 보스 이미지와 사운드를 로딩합니다.
onMount에서 보스가 등장하는 연출을 시작합니다. update에서 보스의 AI와 공격 패턴을 처리합니다.
플레이어가 보스를 쓰러뜨리면 onRemove에서 폭발 효과를 정리하고 메모리를 해제합니다. 이렇게 각 단계를 명확히 나누면 코드가 깔끔해지고 버그가 줄어듭니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 생성자에서 무거운 작업을 하는 것입니다.
생성자는 동기적으로 실행되기 때문에 이미지 로딩 같은 비동기 작업을 할 수 없습니다. 반드시 onLoad에서 해야 합니다.
또 다른 실수는 onRemove를 구현하지 않는 것입니다. 이렇게 하면 타이머나 이벤트 리스너가 남아서 메모리 누수가 발생합니다.
따라서 onLoad에서 할당한 것은 반드시 onRemove에서 해제해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 자신의 코드를 다시 보았습니다. "아, 제가 onRemove를 안 만들어서 메모리 누수가 생긴 거였군요!" 컴포넌트 라이프사이클을 제대로 이해하면 안정적이고 효율적인 게임을 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 비동기 작업(이미지 로딩 등)은 반드시 onLoad에서 하세요
- onLoad에서 할당한 리소스는 반드시 onRemove에서 해제하세요
- parent나 game에 접근해야 한다면 onMount 이후에 하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
타일맵 시스템 완벽 가이드
Flame 게임 엔진에서 타일맵을 활용하여 효율적으로 게임 월드를 구성하는 방법을 배웁니다. Tiled 에디터부터 충돌 처리, 동적 타일 변경까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.
Flutter/Flame 커스텀 렌더링 파이프라인 완벽 가이드
게임 엔진의 핵심인 렌더링 파이프라인을 직접 커스터마이징하는 방법을 배웁니다. Canvas부터 셰이더, 배치 렌더링까지 실무에서 바로 쓸 수 있는 최적화 기법을 소개합니다.
Flame 고급 컴포넌트 패턴 완벽 가이드
Flutter의 Flame 엔진으로 게임을 만들 때 필수적인 고급 컴포넌트 패턴을 배워봅니다. 상속, Mixin, HasGameRef 등 실무에서 자주 쓰이는 패턴을 초급자도 이해할 수 있도록 쉽게 설명합니다.
Flame 소개 및 환경 설정 완벽 가이드
Flutter 기반의 2D 게임 엔진 Flame의 기초부터 환경 설정까지 차근차근 알아봅니다. 초급 개발자도 쉽게 따라할 수 있도록 실무 중심으로 설명합니다.
Riverpod 3.0 쇼핑 앱 종합 프로젝트 완벽 가이드
Flutter와 Riverpod 3.0을 활용한 실무 수준의 쇼핑 앱 개발 과정을 단계별로 학습합니다. 상품 목록, 장바구니, 주문, 인증, 검색 기능까지 모든 핵심 기능을 구현하며 상태 관리의 실전 노하우를 익힙니다.