Discover us

About us

Projects

Blog

Events

Members

Development Blog

GDGoC CAU 개발자와 디자이너의 작업 과정과
결과물을 공유하는 공간입니다.

어떻게 프로젝트를 시작하게 되었고,
진행하면서 느낀 개발자와 디자이너의
생생한 스토리를 직접 확인해보세요!

Development

Flutter Ch.1 Flame 개요 및 간단 실습

  • #Flutter
  • Junseob Kim
  • 2024. 10. 3.

Flutter Ch.1 Flame 개요 및 간단 실습

Flutter로 게임을 개발해보자

이번 학기의 모바일 스터디는 flutter를 활용하여 색다른 프로젝트를 해보면서 다양한 인사이트를 얻는 것을 목표로 삼았습니다. 이에 따라 기존 앱 개발, 응용 프로그램 제작 등이 아닌 게임을 개발하는 것을 목표로 삼았습니다. Flame Framework 를 사용하여 게임을 개발해보면서 Dart 및 Flutter의 활용 범위를 넓히고, 타 엔진들 과의 구조 비교를 해보면서 다른 계열의 서비스를 개발할 때 적용할 수 있는 아이디어를 축적하는 것에 초점을 두고 프로젝트를 진행할 예정입니다.

Flame이란 무엇인가

FlameFlutter에서 2D 게임을 개발할 수 있게 해주는 경량화된 게임 엔진입니다. 간단한 구조로, Flutter의 기존 위젯들과 원활하게 통합되며, 애니메이션, 물리 엔진, 충돌 감지 등의 기능을 제공합니다. Flame은 빠르게 게임을 개발할 수 있도록 다양한 도구와 유틸리티를 제공하며, Flutter 생태계를 활용하여 다양한 플랫폼에서 게임을 배포할 수 있다는 장점이 있습니다.

Flame의 장점

  • Flutter 통합: Flutter 위젯과 함께 사용 가능하여 UI와 게임 로직을 쉽게 결합할 수 있음.
  • 다양한 플랫폼 지원: Flutter 기반이므로 Android, iOS, 웹, 데스크톱 등 여러 플랫폼에 게임을 배포 가능.
  • 경량 엔진: 작은 프로젝트에 적합한 경량화된 게임 엔진으로, 빠른 개발이 가능.
  • 풍부한 기능 제공: 애니메이션, 물리 엔진, 충돌 감지 등 2D 게임 개발에 필요한 다양한 유틸리티 지원.

타 엔진과의 비교

특징
Unreal Engine
Unity
Flame
주 용도
고사양 3D 게임, 영화 제작, VR/AR 콘텐츠
2D/3D 게임, AR/VR, 모바일 및 데스크톱 앱 개발
2D 게임, 모바일 및 웹 게임 개발
언어
C++ (Blueprint로 시각적 스크립팅 지원)
C#
Dart
그래픽 성능
매우 높은 그래픽 품질, 리얼타임 렌더링 지원
높은 그래픽 품질 제공, 경량화된 3D 엔진
경량 2D 그래픽에 최적화
사용 난이도
높은 진입장벽 (복잡한 기능과 고급 설정)
초보자부터 전문가까지 다양한 난이도로 접근 가능
Flutter와 유사한 사용 방식, 간단한 구조
플랫폼 지원
콘솔, PC, 모바일, VR/AR, 웹 등
콘솔, PC, 모바일, VR/AR, 웹 등
모바일(Android, iOS), 웹, 데스크톱 (Flutter 기반)
커뮤니티
대형 커뮤니티와 많은 학습 자료
대규모 커뮤니티 및 풍부한 학습 리소스
Flutter 기반 커뮤니티 활용 가능
라이선스 비용
로열티 기반 (수익이 일정 수준 이상 시 로열티 발생)
무료/유료 (특정 수익 이상 시 비용 발생)
무료 (오픈소스)
확장성
AAA급 게임 제작에 적합한 확장성 제공
Plug-in 및 Asset store로 확장성 뛰어남
Flutter 패키지를 활용한 간단한 확장성
2D/3D 지원
2D와 3D 모두 지원 (주로 3D에 최적화)
2D와 3D 모두 균형 있게 지원
2D 전용

Flame 개요

Components

flowchart TD Component --> t[TimerComponent\nParticleComponent\nSpriteBatchComponent] Component --> e[Effects] Component --> PositionComponent Component --> FlameGame Game --> FlameGame PositionComponent --> sprite[SpriteComponent\nSpriteGroupCompponent\nSpriteAnimationComponent\nSpriteAnimationGroupComponent\nParallaxComponent\nIsoMetricTileMapComponent] PositionComponent --> HudMarginComponent HudMarginComponent --> hudGui[HudButtonComponent\nJoystickComponent] PositionComponent --> gui[ButtonComponent\nCustomPainterComponent\nShapeComponent\nSpriteButtonComponent\nTextComponent\nTextBoxComponent\nNineTitleBoxComponent]
모든 Components 는 Component 를 상속받습니다. 또한 다른 Components 를 자식 클래스로 가질 수 있습니다. 해당 시스템을 Flame Component System 또는 FCS 라고 부릅니다.

Component Lifecycle

flowchart LR ET[Runs Each Tick] ~~~ AR[Runs On Add & Resize] AR ~~~ RO[Runs Once] oL[onLoad] --> onGameResize onGameResize --> oM[onMount] oM --> update update --> render render --> update render -- if_removed --> oR[onRemove] oR -. if_re_parented.-> oL style AR fill:#fcf style RO fill:#f9f style oL fill:#f9f style oM fill:#f9f style oR fill:#f9f style onGameResize fill:#fcf
  • onLoad
    • onLoad는 component 의 비동기 초기화시 실행하는 method 입니다.
    • 해당 method는 component 의 life cycle 중 한 번만 실행되므로 asynchronous constructor로도 여길 수 있습니다.
  • onGameResize
    • onGameResize 는 화면 크기 변경 시마다 호출됩니다.
    • 또한, component 가 component tree에 추가될 때 또한 호출됩니다.
  • onParentResize
    • onParentResizeonGameResize 와 비슷하게, component가 component tree에 추가될 때 호출됩니다.
    • 또한, parent component의 크기 변경 시마다 호출됩니다.
  • onMount
    • onMount 는 component가 Game Tree에 추가될 때마다 실행됩니다.
    • 해당 method 내에서 late final변수를 초기화하는 것은 권장하지 않습니다.
    • parent component 가 mount 된 상태여야 해당 method 가 호출되며, 조건 미충족 시 parent component가 mount 될 때까지 기다립니다.
  • onRemove
    • onRemove 는 component가 Game Tree 제거되기 전에 실행할 code를 재정의합니다.
    • 해당 method는 component가 부모의 remove를 통해 제거되든, component의 remove 메서드를 통해 제거되든 상관없이 한 번만 실행됩니다.
  • onChildChanged
    • onChildChanged 는 부모의 child component의 변경을 감지해야 할 때 실행할 code를 재정의합니다.
    • 해당 method는 자식이 부모에 추가되거나 제거될 때마다 호출되며, 자식이 부모를 변경할 때도 호출됩니다.
    • parameter는 target child compnent와 해당 자식이 겪은 변화(추가 또는 제거)가 포함됩니다.

PositionComponent

PositionComponent 는 화면에 위치하는 object를 의미하며 위치와 크기를 가지는 component는 모두 이에 해당합니다.(e.g. 움직이는 사각형, 회전하는 이미지) 때에 따라 PositionComponet 들의 Group 또한 PositionComponent입니다.
기본 PosditionComponent의 경우 다음과 같은 변수를 가집니다.
  • Position
    • Vector2 형식으로 component 의 anchor의 부모에 따른 상대 좌표를 나타냅니다.
    • 만약 부모가 FlameGame인 경우 viewport 기준의 좌표를 나타냅니다.
  • Size
    • camerad의 zoom level 이 1.0일 때 기준의 크기입니다.
    • 해당 값은 부모의 영향을 받지 않습니다.
  • Scale
    • Vector2 형식으로 해당 component 및 children의 배율 값을 나타냅니다.
    • x와 y값을 동일하게 입력할 경우 비율이 유지되며, 두 값이 다를 경우 그 반대입니다.
  • Angle
    • Double이며 anchor를 기준으로 하여 회전된 각도이며 radian으로 나타냅니다..
    • 해당 값은 부모의 각도에 영향을 받습니다
  • Anchor
    • Position, rotation 의 기준이 되는 점입니다.
    • 기본 값은 top left 입니다.

FlameGame

FlameGame class는 Game에 기반한 Component입니다. 해당 class는 Game에 포함된 모든 객체에 있는 update, render 함수를 호출합니다.
 

Low-level API

flowchart BT b[Normal Class]~~~ a[Abstract Class] OxygenGame --Extends--> Game GameWidget --> Game FlameGame -- With --> Game FlameGame -- Extends --> Component oComp[Other Components] -- Extends --> Component style Game fill:#f9f style a fill:#f9f
추상 클래스인 Game은 게임 엔진의 구조를 직접 구현하고자 할 때 사용하는 저수준 API입니다. Game 클래스는 예를 들어 updaterender와 같은 기능을 기본적으로 구현하지 않으며, 이러한 기능은 개발자가 직접 정의해야 합니다.
이 클래스는 또한 onLoad, onMount, onRemove와 같은 라이프사이클 메서드를 포함하고 있습니다. 이러한 메서드들은 GameWidget이나 다른 부모 위젯에서 게임이 로드되고 마운트될 때, 혹은 제거될 때 호출됩니다.
  • onLoad: 이 메서드는 클래스가 부모에 처음 추가될 때 한 번만 호출됩니다.
  • onMount: onLoad가 호출된 이후에 호출되며, 이 클래스가 새로운 부모에 추가될 때마다 실행됩니다.
  • onRemove: 이 메서드는 클래스가 부모에서 제거될 때 호출됩니다.

GameWidget

class GameWidget<T extends Game> extends StatefulWidget
GameWidget은 Flutter widget으로, Game instance를 Flutter의 widget treee에 삽입하는 데 사용됩니다. GameWidget은 충분히 많은 기능을 제공하므로 Flutter application의 root로 실행될 수 있습니다. 따라서 GameWidget을 사용하는 가장 간단한 방법은 다음과 같습니다:
void main() { runApp( GameWidget(game: MyGame()), ); }
위와 같이 GameWidgetrunApp에 전달하여 MyGame 인스턴스를 실행할 수 있습니다.
동시에 GameWidget은 일반적인 Flutter 위젯이므로, Flutter 위젯 트리 내에 어디든 삽입될 수 있습니다. 하나의 앱 내에서 여러 개의 GameWidget을 사용하는 것도 가능합니다.
이 위젯의 레이아웃 동작은 가능한 모든 공간을 채우도록 확장됩니다. 따라서 루트 위젯으로 사용될 경우 앱이 전체 화면을 차지하게 됩니다. 다른 레이아웃 위젯 내에 삽입될 경우에도 가능한 많은 공간을 차지하려고 합니다.

Asset

Flame은 표준 Flutter 의 asset directory 이용하여 audio, images 와 같은 resources를 저장합니다.
. └── assets ├── audio │ └── explosion.mp3 ├── images │ ├── enemy.png │ ├── player.png │ └── spritesheet.png └── tiles ├── level.tmx └── map.json
💡
해당 파일들은 모두 pubspec.yaml 파일 내에 기재되어야 합니다.
flutter: assets: - assets/audio/explosion.mp3 - assets/images/player.png - assets/images/enemy.png - assets/tiles/level.tmx

간단 실습

Flame 을 활용한 Racing Game Demo 개발에 대한 글을 참고하며 실제 코드를 작성해보았습니다.
notion image

구현 시 추가 학습 내용

Collision Detection

  • FlameGame을 상속 받은 class에 HasCollisionDetection mixin 을 추가하면 해당 게임에서 Collision detection 관련 기능이 활성화됩니다.
  • Collision Detection 을 적용할 객체에는 CollisionCallbacks mixin 을 추가하여 Collider 가 포함된 component임을 선언합니다.
    • 충돌하는 두 object 모두 CollisionCallbacks 를 포함해야 합니다.
  • onComponentTypeCheck 함수를 override 하여 노랑색 사각형(star), 와 파란색 사각형(player)의 충돌 시에만 onCollisionStart 함수를 실행하도록 합니다.
    • onCollisionStart 함수를 override 하여 충돌체가 player 인 경우 해당 star object를 부모로부터 삭제하도록 합니다.

Tap 인식 및 좌표 값 이용

  • FlameGame을 상속 받은 class에 TapCallbacks mixin 을 추가하면 해당 게임에서 관련 기능이 활성화됩니다.
  • onTapDown 은 Tap 시 1회 발생하는 함수로, parameter로는 TapDowEvent 가 있습니다.
  • TapDownEvent.canvasPosition 깂은vector2 형식으로 tap한 위치를 나타넵니다.

작성 코드

main.dart
// main.dart import 'package:demo_racing/game_screen.dart'; import 'package:flutter/material.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flame Demo', theme: ThemeData( useMaterial3: true, ), home: const GameScreen(), ); } }
game_screeen.dart
// game_screen.dart import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flame/game.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:demo_racing/game_objects.dart'; import 'package:flame/collisions.dart'; class GameScreen extends StatelessWidget { const GameScreen({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: GameWidget( game: RacingGame(), ), ); } } class RacingGame extends FlameGame with HasCollisionDetection, TapCallbacks{ late Player player; double nextSpawnSeconds = 0; @override Future<void> onLoad() async { player = Player( position: Vector2(size.x * 0.25, size.y - 20), ); add(player); } @override void onTapDown(TapDownEvent event) { super.onTapDown(event); if (!event.handled) { final touchPoint = event.canvasPosition; if (touchPoint.x > size.x / 2) { player.position = Vector2(size.x * 0.75, size.y - 20); } else { player.position = Vector2(size.x * 0.25, size.y - 20); } } } @override void update(dt) { super.update(dt); nextSpawnSeconds -= dt; if (nextSpawnSeconds < 0) { add(Star(Vector2(size.x * (Random().nextInt(10) > 5 ? 0.75 : 0.25), 0))); nextSpawnSeconds = 0.3 + Random().nextDouble() * 2; } } }
game_objects.dart
import 'package:flame/collisions.dart'; import 'package:flame/components.dart'; import 'package:flutter/material.dart'; class Player extends RectangleComponent with CollisionCallbacks { static const playerSize = 96.0; int totalCount = 0; late TextComponent textComponent; Player({required dynamic position}) : super( position: position, size: Vector2.all(playerSize), anchor: Anchor.bottomCenter, ); @override Future<void> onLoad() async { super.onLoad(); paint.color = Colors.blue; add(RectangleHitbox()); textComponent = TextComponent( text: '', textRenderer: TextPaint( style: const TextStyle( fontSize: 40, ), ), anchor: Anchor.center, position: Vector2(playerSize / 2, playerSize / 2), ); add(textComponent); textComponent.text = "0"; } @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { if (other is Star) { textComponent.text = '${++totalCount}'; } else { super.onCollisionStart(intersectionPoints, other); } } } class Star extends RectangleComponent with HasGameRef, CollisionCallbacks { static const starSize = 64.0; Star(position) : super( position: position, size: Vector2.all(starSize), anchor: Anchor.bottomCenter, ); @override Future<void> onLoad() async { super.onLoad(); paint.color = Colors.yellow; add(RectangleHitbox()); } @override void update(double dt) { super.update(dt); position.y = position.y + 5; if (position.y - starSize > gameRef.size.y) { removeFromParent(); } } @override bool onComponentTypeCheck(PositionComponent other) { if (other is Star) { return false; } else { return super.onComponentTypeCheck(other); } } @override void onCollisionStart( Set<Vector2> intersectionPoints, PositionComponent other, ) { if (other is Player) { removeFromParent(); } else { super.onCollisionStart(intersectionPoints, other); } } }