[Flutter] Flame 엔진으로 간단한 게임을 만들어보자

S_Soo100·2023년 10월 30일
2

flutter

목록 보기
14/14
post-thumbnail
  • Flutter의 게임 엔진 Flame으로 간단한 게임을 구현해보자.
    • 공식 문서 : 링크
    • 구글 코드랩스 교육문서 : 링크

0. 아이디어 스케치

  • 화면 최상단에서 오브젝트가 생성되서 내려오고, 화면 최하단에 있는 플레이어 캐릭터는 좌우로 움직이며 그것을 피하는 게임을 만들어보기로 했다.
  • 어릴적 이런 형태의 간단한 게임들을 많이 봤을 것이다.
    우선 오브젝트 생성, 움직임, 충돌판정 등을 구현하고, 이미지는 여기에서 무료로 가져다 쓰기로 했다.

1. Flutter Flame 기본 구조

1.1 설치

  • 우선 신규 프로젝트에 플레임을 설치하자.

    	 $ flutter pub add flame
  • 플레임 엔진으로 만든 게임은 플러터의 다른 위젯처럼 사용이 가능하다.
    단, GameWidget()으로 게임 인스턴스를 감싸서 렌더링 해야 한다.

    import 'package:flame/game.dart';
    import 'package:flutter/material.dart';
    
    class YourGamePage extends StatefulWidget {
      const YourGamePage({super.key});
    
      
      State<YourGamePage> createState() => _YourGamePageState();
    }
    
    class _YourGamePageState extends State<YourGamePage> {
      
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text("Airplane Game"),
          ),
          body: Center(
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: GameWidget(
                game: YourGame(), // 이부분에 게임 인스턴스를 넣어준다.
              ),
            ),
          ),
        );
      }
    }
    
  • 위의 구조만 따라가면 게임을 어느 크기로든, 어느 위치에서든 편리하게 불러낼 수 있다.
    물론 직접 YourGame() 같이 구현할 필요 없이, 다른데서 주입해주거나 매번 인스턴스를 새로 생성하게 할수도 있다.

1.2. FlameGame 클래스

  • GameWidget안에 넣는 FlameGame클래스는 우리가 Stateless위젯 만들듯이 extends 해주면 된다.
    기본적으로 @override해서 사용할 수 있는 함수들을 많이 제공하는데, 대부분 그것들과 섞어서 쓰면 된다.
    그 외에도 여러 Mixin을 섞어서 탭 하는걸 인식하거나 충돌을 인식하거나 할 수 있으며,
    이는 컴포넌트 레벨에서도 동일하게 적용된다.
    우리는 컴포넌트를 구현 할 때 충돌 인식을 구현할 것이다.

    import 'package:flame/game.dart';
    
    class YourGame extends FlameGame {
    	
      // 게임 인스턴스가 생성될때 실행하는 함수, 대부분 여기에 내용을 배치한다.
      
      Future<void> onLoad() async {
        super.onLoad();
      }
      
      // 업데이트 되는 매 프레임마다 실행되는 로직
      
      void update(double dt) async {
        super.update(dt);
      }
      
      // 인스턴스가 해제될 떄 실행되는 로직
      
      void onRemove() {
     	super.onRemove();
      }
    }

1.3. 컴포넌트 클래스 - 배경화면 구현

  • 이제 컴포넌트를 만들어보자. 웹에서 처럼 게임에서 무언가를 구성하는 요소들을 모두 컴포넌트(Component)라고 부른다.
    flame/components를 상속하는 새로운 클래스를 만들어 보자.

  • 우리는 스프라이트 이미지를 이용해서 게임을 만들 것이기 때문에 SpriteComponent를 상속하면 된다.
    그리고 onLoad() 함수에 스프라이트를 불러와준다.

    import 'package:flame/components.dart';
    
    class AirplaneGameBg extends SpriteComponent with HasGameRef {
      
      Future<void> onLoad() async {
        super.onLoad();
        sprite = await gameRef.loadSprite('your_background_image.jpg');
        size = Vector2(gameRef.size.x, gameRef.size.y);
      }
    }
    
  • HasGameRef mixin은 플러터 위젯의 context처럼, 지금 이 컴포넌트를 자식으로 가지는, 부모 게임 인스턴스의 여러 요소들을 끌어다 쓸 수 있게 해준다.
    여기서는 게임판의 size를 받아와서 스프라이트 이미지를 불러오는 우리 컴포넌트의 크기를 게임판의 크기로 맞추고 있다.
    컴포넌트를 구성했다면 이제 게임에서 불러와야 한다. 게임 코드로 가서 백그라운드 컴포넌트를 불러내보자.

  • await add() 메서드로 컴포넌트를 불러와주면 된다. 이건 onLoad()시에만 사용 가능한 메서드가 아니며, 언제든 불러낼 수 있다.

    import 'package:flame/game.dart';
    
    class YourGame extends FlameGame {
     // 게임 인스턴스가 생성될때 실행하는 함수, 대부분 여기에 내용을 배치한다.
     
     Future<void> onLoad() async {
       final AirplaneGameBg _ = new AirplaneGameBg();
       await add(_);
       super.onLoad();
     }
     ...
     ...
    }
- 자 이제 배경이 만들어진걸 알 수 있다.

1.4. 오브젝트 배치

  • 이제 또 SpriteComponent를 사용해서 우리가 플레이 할 플레이어 캐릭터 비행기를 만들어보자.

  • 기본 크기는 가로 세로 모두 60프레임으로 지정해두고, 만들어낼 위치(포지션)와 충돌 이벤트때 받아야 하는 함수만 외부에서 넣어주자.
    그리고 충돌도 구현하기로 했으니, 동그랗게 히트박스도 만들어서 표시해주자.

    import 'package:flame/collisions.dart';
    import 'package:flame/components.dart';
    import 'package:flutter/material.dart';
    
    class PlayerPlane extends SpriteComponent with HasGameRef, CollisionCallbacks {
      static const double playerSize = 60.0;
      final Function hitAction;
      PlayerPlane({required position, required this.hitAction})
          : super(size: Vector2.all(playerSize), position: position);
    
      late ShapeHitbox hitbox;
    
      
      void onLoad() async {
        super.onLoad();
        sprite = await gameRef.loadSprite('player.png');
        position = position;
    
        // 빨간색 원으로 잘 보이는 히트박스 만들어주기
        final Paint defaultPaint = Paint()
          ..color = Colors.red
          ..style = PaintingStyle.stroke;
        hitbox = CircleHitbox()
          ..paint = defaultPaint // paint를 하지 않거나 선을 투명으로 하면 보이지 않음
          ..renderShape = true;
        add(hitbox);
      }
    
      // CollisionCallbacks Mixin 사용시 @override 가능, 충돌 발생시 메서드
      
      void onCollision(Set<Vector2> points, PositionComponent other) {
        super.onCollision(points, other);
        if (other is ScreenHitbox) {
            // 게임 화면과 충돌했을 때 메서드 정의
        } else if (other is EnemyPlain) {
            // other의 타입을 지정해서 충돌이 일어나는 컴포넌트별로 분기할 수 있다. 
            hitAction(); //외부로 받아온 매개변수
        }
      }
    }
    
  • 컴포넌트를 어느정도 정리했으면 이제 배경화면 위에 띄워보자.

    import 'package:flame/game.dart';
    
    class YourGame extends FlameGame {
     final YourBackground _backGround = YourBackground();
     late PlayerPlane _player;
    	
     // 게임 인스턴스가 생성될때 실행하는 함수, 대부분 여기에 내용을 배치한다.
     
     Future<void> onLoad() async {
       await add(_gameBg);
       _player = PlayerPlane(
           position: Vector2(size.x / 2 - 30, size.y - 100), hitAction: hitAction);
       await add(_player);
       super.onLoad();
     }
     ...
     ...
    }
  • 이미지와 빨간 히트박스 구현이 된 것을 확인할 수 있다.
    그리고 FlameGame 안에서 _player로 불러냈기 때문에, PlayerPlane클래스의 메서드도 사용할 수 있다.
    움직이거나, 폭파시키거나, 형태를 변화하거나 등등 원하는 메서드를 만들어 사용하면 된다.

  • 이제 적군 비행기 클래스를 만들어보자.

    // enum으로 비행기의 상태를 관리하자, 날고있는지 아니면 충돌했는지
    enum EnemyPlainState { flying, hit }
    
    class EnemyPlain extends SpriteComponent with HasGameRef, CollisionCallbacks {
      // 우리 플레이어 비행기랑 똑같이 60픽셀로
      static const double enemySize = 60.0;
      EnemyPlain({required position})
          : super(size: Vector2.all(enemySize), position: position);
    
      //초기 상태는 비행중으로
      EnemyPlainState _state = EnemyPlainState.flying;
      //getter를 써서 상태를 외부에서 변경 못하도록 
      EnemyPlainState get state => _state;
      late ShapeHitbox hitbox;
      late Sprite? _spirte;
    
      
      void onLoad() async {
        super.onLoad();
        _spirte = await gameRef.loadSprite("적 비행기 이미지");
        sprite = _spirte;
        position = position;
    
        // 플레이어 비행기랑 똑같이 빨간 선으로 히트박스 표시
        final Paint defaultPaint = Paint()
          ..color = Colors.red
          ..style = PaintingStyle.stroke;
        hitbox = CircleHitbox()
          ..paint = defaultPaint
          ..renderShape = true;
        add(hitbox);
      }
    
      
      void update(double dt) {
        // y값(세로)을 변경해서 날아가도록 수정해보자
        fly();
        super.update(dt);
      }
    
      void fly(){
        // y값을 변경하며 매 프레임마다 아래로 날아가도록 로직을 구현하자
      }
    
      
      void onCollision(Set<Vector2> points, PositionComponent other) {
        super.onCollision(points, other);
        if (other is ScreenHitbox) {
          // 스크린과 충돌했을 때 로직
        } else if (other is PlayerPlane) {
          // 플레이어 비행기와 충돌했을 때 로직
        }
      }
    }
    
  • 아직 만들어야 할 메서드가 많지만 기본형은 이렇게 될 거 같다.
    이 적군 비행기는 Player비행기처럼 처음부터 만들어두지 않고, Timer를 써서 일정 시간마다 만들어서 위에서 아래로 내려오게 하자.

  • 이제 게임 코드에서 EnemyPlane을 만들어내는 로직 부분을 수정하자.

2. 게임 로직 만들기

  • Flame에서 컴포넌트의 위치(포지션)를 변경할 수 있는 방법은 2가지가 있다.
    1. 컴포넌트 내부에서 자신의 포지션을 변경하는 함수
    2. 컴포넌트를 불러온 부모 컴포넌트 혹은 부모 게임에서
      "그" 컴포넌트의 포지션을 변경하는 함수
  • 적군 비행기 클래스는 본인들이 알아서 움직여야 하니 1번 방식을 택하고,
    플레이어 비행기는 외부 오버레이 위젯(최상단에 띄워놓는 버튼 등)을 이용해서도 움직이게 할 게획이니, 2번 방식을 택하자.

2.1. 내 캐릭터 움직이기 + 외부 오버레이 위젯

  • 우선 제일 중요한 내 캐릭터부터 컨트롤해보자
    YourGame 클래스 안에 두 메서드를 추가한다.

      void flyLeft() {
        _player.position = Vector2(_player.position.x - 10, _player.position.y);
      }
    
      void flyRight() {
        _player.position = Vector2(_player.position.x + 10, _player.position.y);
      }
  • x좌표는 왼 쪽 끝이 0이다. flyLeft는 왼 쪽으로 10픽셀, flyRight는 오른쪽으로 10 픽셀 움직여준다.

  • 그리고 이러다가 화면 밖으로 나가버리면 안되니, 벽이랑 부딛히면 더 가지 말고 멈추라는 함수를 추가하자.

      
      void onCollision(Set<Vector2> points, PositionComponent other) {
        super.onCollision(points, other);
        // 스크린과 부딛히면?
        if (other is ScreenHitbox) {
    		// 내 비행기의 위치가 게임판의 x값보다 크면?
          // 그대로 x값 0에 고정
          if (position.x < size.x) {
            position = Vector2(0, position.y);?
          // 그게 아니면? 겹치지 않는 맨 끝 위치에 고정
          } else {
            position = Vector2(game.size.x - size.x, position.y);
          }
        }
        ...생략
      }
  • 이제 이 기능들을 내가 탭해서 쓸 수 있도록, 게임 상단에서 이놈들을 불러다 쓸 버튼 두 개를 화면에 배치하자
    이를 위해서는 GameWidget을 가지고 있는 부모 위젯에서 코드를 수정해줄 필요가 있다.

    class YourGamePage extends StatefulWidget {
      const YourGamePage({super.key});
    
      
      State<YourGamePage> createState() => _YourGamePageState();
    }
    
    class _YourGamePageState extends State<YourGamePage> {
    
      
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text("Airplane Game"),
          ),
          body: Center(
            child: GameWidget(
              game: YourGame(),
              // Map<String, Widget Function> 타입의 overlayBuilderMap을 추가한다.
              // String으로 Overlay 이름을 정하고 위젯을 만들어두면 Game내에서 overlay.add 가능
              overlayBuilderMap: {
              	// context와 game을 모두 참조할 수 있다.
                  // game의 클래스 타입을 제대로 명시해주지 않으면 내부메서드 사용이 어렵다.
                'leftRightButton': (BuildContext context, YourGame game) {
                  return Align(
                    alignment: Alignment.center,
                    child: Padding(
                      padding: const EdgeInsets.all(12.0),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          ElevatedButton(
                              onPressed: () {
                                // 왼쪽으로 이동
                                game.flyLeft();
                              },
                              child: Text(">")),
                          ElevatedButton(
                              onPressed: () {
                                // 오른쪽으로 이동
                                game.flyRight();
                              },
                              child: Text(">")),
                        ],
                      ),
                    ),
                  );
                },
              },
            ),
          ),
        );
      }
    }
    
  • Overlay맵을 정의했다면 이제 Game쪽 코드를 수정하자!

    class YourGame extends FlameGame with TapCallbacks, HasCollisionDetection {
      late PlayerPlane _player;
    
      void hitAction() {}
    
      
      Future<void> onLoad() async {
        final AirplaneGameBg _ = new AirplaneGameBg();
        await add(_);
        _player = PlayerPlane(
            position: Vector2(size.x / 2 - 30, size.y - 100), hitAction: hitAction);
        await add(_player);
        // overlay.add를 하면 상위에 정의해둔 String을 Key로 오버레이를 불러온다.
        overlays.add("leftRightButton");
        super.onLoad();
      }
    
      void flyLeft() {
        _player.position = Vector2(_player.position.x - 17, _player.position.y);
      }
    
      void flyRight() {
        _player.position = Vector2(_player.position.x + 17, _player.position.y);
      }
    }
    

    이제 확인해보면 잘 올라왔다.

  • 파란색으로 좌/우 버튼이 추가된걸 볼 수 있다(지금 보니 둘다 ">"로 해놨다.)
    눌러서 확인해보니 YourGame내에 정의한 함수가 잘 움직이고 있다.

2.2. 적군 캐릭터 랜덤 추가

  • 우선 적 비행기를 랜덤하게 만들어보자.
    YourGame의 onLoad() 메서드에 타이머와 적군 비행기 추가 로직을 추가하자.

    import 'dart:math';
    import 'dart:async' as ASYNC;
    
    class YourGame extends FlameGame with TapCallbacks, HasCollisionDetection {
      late PlayerPlane _player;
      late ASYNC.Timer? _enemyTimer;
      late ASYNC.Timer? _enemyTimer2;
    	
      // 아직 임시
      void hitAction() {}
    
      
      Future<void> onLoad() async {
        final AirplaneGameBg _ = new AirplaneGameBg();
        await add(_);
        _player = PlayerPlane(
            position: Vector2(size.x / 2 - 30, size.y - 100), hitAction: hitAction);
        await add(_player);
        overlays.add("leftRightButton");
    
    	// 타이머 한개는 1초마다, 한개는 1.5초마다 적 비행기 생성한다.
        // 타이머 시간은 임의로 수정하면 된다.
        _enemyTimer =
            ASYNC.Timer.periodic(const Duration(milliseconds: 1000), (timer) {
          add(addEnemy());
        });
        _enemyTimer2 =
            ASYNC.Timer.periodic(const Duration(milliseconds: 1500), (timer) {
          add(addEnemy());
        });
        super.onLoad();
      }
    
      EnemyPlain addEnemy() {
        // x축의 위치를 랜덤하게 만들어준다.
        // y축의 위치는 임의로 30으로 고정한다.
        int randomDx = Random().nextInt(13) + 1;
        return EnemyPlain(position: Vector2(randomDx * 30, 30));
      }
    
      void flyLeft() {
        _player.position = Vector2(_player.position.x - 17, _player.position.y);
      }
    
      void flyRight() {
        _player.position = Vector2(_player.position.x + 17, _player.position.y);
      }
    }
    

    자 추가가 잘 되고 있다.

2.3. 적군 캐릭터 움직임, 피격시 반응 추가

  • 이제 EnemyPlain 클래스를 수정할 차례다.
    5가지의 기능을 추가해야 한다.

    1. 생성될 때 랜덤한 스프라이트 보여주기
    2. 알아서 아래쪽으로 내려가게 만들기(속도는 랜덤하게)
    3. PlayerPlane과 부딛히면 깜빡이며 사라지게 만들기
    4. 바닥과 충돌하면 사라지게 만들기
    5. 생성되었을 때 x축 맨 끝과 겹쳐지게 만들어지면 안쪽으로 위치 옮기기
  • 꽤 많아 보이지만 코드로 보면 간단할거다!

    import 'dart:math';
    import 'dart:async' as ASYNC;
    import 'package:flame/collisions.dart';
    import 'package:flame/components.dart';
    import 'package:flutter/material.dart';
    import 'package:get/get.dart';
    
    enum EnemyPlainState { flying, hit }
    
    class EnemyPlain extends SpriteComponent with HasGameRef, CollisionCallbacks {
      static const double enemySize = 60.0;
      final int speed;
      EnemyPlain({required position, required this.speed})
          : super(size: Vector2.all(enemySize), position: position);
    
      late ShapeHitbox hitbox;
      EnemyPlainState _state = EnemyPlainState.flying;
      EnemyPlainState get state => _state;
      late String planeType;
      late Sprite? _spirte;
    
    	// 실행시 랜덤하게 Sprite를 결정해주자!
      String initRandomType() {
        int type = Random().nextInt(4);
        switch (type) {
          case 0:
            return 'airplane_game/enemies/enemy1-1.png';
          case 1:
            return 'airplane_game/enemies/enemy2-1.png';
          case 2:
            return 'airplane_game/enemies/enemy3-1.png';
          case 3:
            return 'airplane_game/choppers/chopper2-2.png';
          default:
            return 'airplane_game/enemies/enemy3-1.png';
        }
      }
    
      
      void onLoad() async {
        super.onLoad();
    	  // 실행시 랜덤하게 Sprite를 결정해주자!
        planeType = initRandomType();
    	  // 랜덤하게 결정한 스프라이트를 로드한 놈을 할당해주기
        _spirte = await gameRef.loadSprite(planeType);
    	  // 할당한 스프라이트를 컴포넌트에 넘겨주기 
        sprite = _spirte;
        position = position;
    
        final Paint defaultPaint = Paint()
          ..color = Colors.red
          ..style = PaintingStyle.stroke;
        hitbox = CircleHitbox()
          ..paint = defaultPaint
          ..renderShape = true;
        add(hitbox);
      }
    
      
      void update(double dt) {
    	// 외부에서 속도를 받아와서, flying(날고있는)상태면 계속 y값을 아래로 내리기
        if (_state == EnemyPlainState.flying) {
          position = Vector2(position.x, position.y + speed);
        }
        super.update(dt);
      }
    
      
      void onCollision(Set<Vector2> points, PositionComponent other) {
        super.onCollision(points, other);
        if (other is ScreenHitbox) {
      	// PlayerPlane때 한대로 비슷하게 구현하면 된다.
          if (position.x > game.size.x) {
            if (position.x < size.x) {
              position = Vector2(0, position.y);
              return;
            } else {
              position = Vector2(game.size.x - size.x, position.y);
              return;
            }
          }
          // removeFromParent는 컴포넌트 클래스의 내부함수로, 이 컴포넌트를 해제한다.
          // 나랑 안 부딛히고 바닥에 부딛히면 없애버리자.
          if (position.y + size.y > game.size.y) {
            removeFromParent();
          }
        // player랑 부딛히면 비행기를 멈추고 사라지게 하는 함수 추가
        } else if (other is PlayerPlane) {
          stopPlane();
        }
      }
    
      ASYNC.Timer? destroyTimer;
    
      void stopPlane() {
        _state = EnemyPlainState.hit;
        destroy();
        Future.delayed(const Duration(seconds: 1), (() => removeFromParent()));
    	// Timer클래스는 다 사용하면 항상 cancel()로 해제하자
        destroyTimer?.cancel();
      }
      
      // PlayerPlane과 충돌나면 깜빡거리는 점멸효과를 1초정도 준다.
      void destroy() async {
        _spirte = await gameRef.loadSprite(planeType);
        bool blink = false;
        sprite = null;
        destroyTimer =
            ASYNC.Timer.periodic(const Duration(milliseconds: 50), (timer) {
          if (blink == true) {
            sprite = null;
            blink = false;
          } else {
            sprite = _spirte;
            blink = true;
          }
        });
      }
    }
    

    이제 잘 굴러가나 체크해보자

  • 랜덤하게 비행기 이미지들도 잘 불러와지고, 아래로 내려오되 속도도 랜덤하다.
    그리고 충돌하면 1초간 깜빡거리며 이후에 잘 사라지고 있다.

  • 여기까지 왔다면 Flutter Flame 엔진의 기초적인 부분은 한번씩 다 사용해봤다고 생각한다.
    이제 직접 게임을 만들며 게임 안에 게임진행상태, HP, SCORE등 내부 상태를 주고 관리하며 게임을 고도화 해보면 좋을 것 같다.

profile
플러터, 리액트

0개의 댓글