Flame을 사용해서 Flutter 게임 만들기

김진한·2022년 5월 29일
5

Flutter

목록 보기
6/11

Flutter를 이용하면 개발자들이 모든 플랫폼에 어플리케이션을 배포할 수 있습니다. 때문에 소프트웨어 회사와 개발자들은 Flutterf를 사용하는 것은 당연합니다.

전톡적으로 모바일 게임 개발자들은 네이티브와 유니티 둘 중 하나를 선택해야 했습니다. 네이티브는 성능이 좋지만 개발 시간이 많이 걸리고, 유니티는 느린 로딩과 앱 사이즈가 커진다는 단점이 있습니다.

이러한 시점에서 Flutter의 Flame은 좋은 도구가 될 수 있습니다. 이번 포스트에서는 Flame 엔진을 사용해서 가상세계를 구축하는 방법을 알아보겠습니다.

  • Flame versiond은 1.0이고 Web, Android, IOS에서 사용할 수 있습니다.
  • game loop를 사용했습니다.
  • 움직이는 플레이어 캐릭터를 구현했습니다.
  • sprite sheets를 사용했습니다.
  • tile map을 사용해서 박스 충돌기능을 추가했습니다.


Start

가로 세로로만 이동할 수 있는 RayWorld라는 게임을 개발해보겠습니다. 우선 starter 아래 링크에서 프로젝트를 다운받아서 실행해주세요. 실행해보면 검은 화면과 joypad만 보일 것입니다. 현재는 보이는 화면은 Flutter로 렌더링 한 것이고, 이후 작업부터는 Flame이 필요합니다.
프로젝트 가이드를 위해 만들었던 start 프로젝트가 제대로 준비되지 않았습니다. 최종 코드 링크를 첨부하겠습니다.
https://github.com/jinhan38/starter



Flame Game Engine

Flame 엔진은 Flutter 2D 게임 개발자들에게 game loop, collision detection, sprite animation과 같은 기능들을 제공합니다.

여기서는 Flame 1.0.0 버전을 사용했습니다.


###Setting up Your Flame Game Loop 먼저 세팅해야 할 것은 Flame game loop 입니다. 여기서 다른 모든 구성 요소들을 만들고 관리합니다.

lib 폴더에서 ray_world_game.dart 파일을 만들고, FlameGame을 상속받는 RayWorGame class를 만들어주세요

class RayWorldGame extends FlameGame{

  
  Future<void> onLoad() async{

  }
}

main_game_page.dart파일에서 방금 만든 RayWorldGame의 인스턴스를 전역변수로 생성해주세요.

class MainGameState extends State<MainGamePage> {
  RayWorldGame game = RayWorldGame();
  
  ...
}

그리고 난 후 TODO 1이라고 되어 있는 부분에(Align widget 윗부분) RayWorldGame의 인스턴스와 함께 GameWidget을 추가해주세요

  
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: const Color.fromRGBO(0, 0, 0, 1),
        body: Stack(
          children: [
            // TODO 1
            GameWidget(game: game),
            
            Align(
              alignment: Alignment.bottomRight,
              child: Padding(
                padding: const EdgeInsets.all(32.0),
                child: Joypad(onDirectionChanged: onJoypadDirectionChanged),
              ),
            )
          ],
        ));
  }

아직은 아무것도 작동하지 않을 것입니다. 이제 기본세팅이 끝났으니 캐릭터를 추가해보겠습니다.



캐릭터 생성

lib에서 components 폴더를 추가해주세요. 앞으로 여기서 모든 Flame 컴포넌트들을 생성 및 관리할 것입니다.
자 그럼 player.dart라는 파일을 생성하고 Player class를 만들어주세요.

import 'package:flame/components.dart';

class Player extends SpriteComponent with HasGameRef {
  Player() : super(size: Vector2.all(50));

  
  Future<void> onLoad() async {
    super.onLoad();
    // TODO 1
  }
}

Player class는 SpriteComponent class를 상속받고 있습니다. SpriteComponent를 사용해서 정적 이미지를 렌더링 할 수 있습니다. 생성자에서는 super를 이용해서 상속받은 SpriteComponent의 size 값을 50으로 설정했습니다.
Flame engine에서 attached된 컴포넌트들은 여러 핵심 기능들이 있지만 여기서는 onLoad함수만 사용하겠습니다.


Player class의 TODO 1에서 캐릭터 이미지를 로드시키고, 초기 위치값을 설정하겠습니다.

  
  Future<void> onLoad() async {
    super.onLoad();
    sprite = await gameRef.loadSprite('player.png');
    position = gameRef.size / 2;
  }

HasGameRef를 추가하면 모든 컴포넌트가 게임 루프에 접근할 수 있습니다. assets폴더에 있는 player.png 파일을 불러오고, 게임의 가운데에 플레이어를 위치시켜보겠습니다.


그럼 이제 RayWorldGame 클래스에 Player를 추가하겠습니다.
class RayWorldGame extends FlameGame{

  final Player _player = Player();
  
  Future<void> onLoad() async{
    add(_player);
  }
}

add는 Flame engine에서 가장 중요한 함수 중 하나입니다. 이를 사용해서 컴포넌트를 등록하고, 화면에 렌더링 시킬 수 있습니다.

자 이제 빌드를 해보면 화면 가운데 플레이어가 있는 것을 볼 수 있습니다.



플레이어 이동시키기

플레이어를 이동시키기 위해서는 조이패드를 움직이는 방향을 알아야 합니다. 방향은 starter 프로젝트에 만들어져 있던 Joypad 위젯을 통해서 감지할 수 있으며, 이는 게임루프 외부에 위치해 있습니다.
입력받은 방향 정보가 GameWidget으로 전달되고, 이는 다시 Player로 전달됩니다.

Player class 전역변수로 Direction를 선언해주세요. Direction은 사전에 정의해 놓은 enum class입니다. 방향값의 상태는 위, 아래, 왼쪽, 오른쪽 그리고, 아무것도 안한 상태가 있습니다.

Direction direction = Direction.none;

다시 RayWorldGame class로 돌아와서 Player class의 direction을 변경시킬 함수를 만들어주세요

void onJoypadDirectionChanged(Direction direction) {
  _player.direction = direction;
}

그리고 main_game_page.dart에서 사전에 starter 프로젝트에서 만들어 놓은 onJoypadDirectionChanged 함수에 아래 로직을 추가해주세요

void onJoypadDirectionChanged(Direction direction) {
  game.onJoypadDirectionChanged(direction);
}

이제 플레이어 컴포넌트에서 조이패드의 방향 정보를 알 수 있습니다. 그럼 플레이어를 움직이기만 하면 되겠죠? Player 클래스에서 update와 movePlayer함수를 만들어주세요.
  
  void update(double delta) {
    super.update(delta);
    movePlayer(delta);
  }

  void movePlayer(double delta ){
	// TODO
  }

update 함수는 Flame 렌더링 할 때마다 호출되며, Flame은 모든 컴포넌트들이 동시에 업데이트시킵니다. delta 값은 마지막 업데이트 주기 이후 경과한 시간을 말해주며, 플레이어를 이동시킬 때 사용합니다. 이제 실제로 플레이어를 이동시킬 함수를 만들겠습니다.

Player 클래스에서 이동 속도를 설정하기 위한 전역변수와 move 함수들을 만들어주세요.

final double _playerSpeed = 300.0;
    
...

  void movePlayer(double delta) {
    switch (direction) {
      case Direction.up:
        moveUp(delta);
        break;
      case Direction.down:
        moveDown(delta);
        break;
      case Direction.left:
        moveLeft(delta);
        break;
      case Direction.right:
        moveRight(delta);
        break;
      case Direction.none:
        break;
    }
  }

  void moveUp(double delta) {
    position.add(Vector2(0, delta * -_playerSpeed));
  }
  
  void moveDown(double delta) {
    position.add(Vector2(0, delta * _playerSpeed));
  }

  void moveLeft(double delta) {
    position.add(Vector2(delta * -_playerSpeed, 0));
  }

  void moveRight(double delta) {
    position.add(Vector2(delta * _playerSpeed, 0));
  }

position.add()에 Vector2() 함수를 넣었는습니다. 아래로만 이동해야하기 때문에 x 값은 0이고, y값을 delta x playerSpeed 값으로 넣었습니다. playerSpeed 를 300으로 선언했으니 delta가 1라면 y값이 150만큼 이동할 것입니다.
또한 플레이어 기준으로 왼쪽과 위로 이동하기 위해서는 pixel 좌표상에서 -가 됩니다. 그래서 moveUp과 moveLeft 함수에서는 playerSpeed 값에 -를 붙였습니다.


빌드를 해서 잘 이동하는지 확인해볼까요?

부드럽게 잘 이동하는 것을 볼 수 있습니다. 그런데 플레이어가 항상 오른쪽만 보고있습니다. 이동하는 방향을 바라보도록 수정이 필요합니다. 이제 Sprite Sheet를 사용할 때가 왔습니다.


What Is a Sprite Sheet?

Sprite Sheet는 sprite 이미지들을 모아놓은 sheet입니다. 게임의 메모리를 절약하고 로딩 시간을 단축시키기 위해서 사용하고 있습니다. 여러 이미지를 로드하는 대신 여러 이미지가 담겨있는 하나의 시트를 로드해서 사용하는 것이 더 빠르겠죠?
아래 이미지가 우리가 사용할 Sprite Sheet입니다. 상하좌우를 바라보고 있는 캐릭터 이미지가 각각 4개씩 총 16개의 캐릭터 이미지가 있습니다.


Flame에서 Sprite Sheet를 사용하는 것은 어렵지 않습니다.
Player class가 상속받고 있는 SpriteComponent를 SpriteAnimationComponent로 변경해주세요. 이를 이용해서 플레이어가 달리는 모습의 애니메이션을 넣을 수 있습니다.
그럼 먼저 아래 패키지를 import 해주세요.

import 'package:flame/sprite.dart';

그리고 몇개의 변수도 추가하겠습니다.

final double _animationSpeed = 0.15;
late final SpriteAnimation _runDownAnimation;
late final SpriteAnimation _runLeftAnimation;
late final SpriteAnimation _runUpAnimation;
late final SpriteAnimation _runRightAnimation;
late final SpriteAnimation _standingAnimation;

loadAnimations 함수 추가와 함 께 onLoad함수도 변경해주세요

  
  Future<void> onLoad() async {
    super.onLoad();
    await _loadAnimations();
    animation = _standingAnimation;
  }

  Future<void> _loadAnimations() async {
    final spriteSheet = SpriteSheet(
      image: await gameRef.images.load('player_spritesheet.png'),
      srcSize: Vector2(29.0, 32.0),
    );

    _runDownAnimation = spriteSheet.createAnimation(
      row: 0,
      stepTime: _animationSpeed,
      to: 4,
    );

    _runLeftAnimation = spriteSheet.createAnimation(
      row: 1,
      stepTime: _animationSpeed,
      to: 4,
    );

    _runUpAnimation = spriteSheet.createAnimation(
      row: 2,
      stepTime: _animationSpeed,
      to: 4,
    );

    _runRightAnimation = spriteSheet.createAnimation(
      row: 3,
      stepTime: _animationSpeed,
      to: 4,
    );

    _standingAnimation =spriteSheet.createAnimation(
      row: 0,
      stepTime: _animationSpeed,
      to: 1,
    );
  }

spriteSheet.createAnimation() 함수를 보면 row와 to값이 있습니다. 앞서 본 Sprite Sheet의 row와 몇번째 까지의 이미지를 사용할지 선택할 수 있습니다.
이제 플레이어를 이동시킬 수 있으니 주변 환경들을 설정해보겠습니다.



Adding a World

components 폴더에 world.dart파일을 추가하고, World 클래스를 생성해주세요

import 'package:flame/components.dart';

class World extends SpriteComponent with HasGameRef {
  
  Future<void> onLoad() async {
    super.onLoad();
    sprite = await gameRef.loadSprite('rayworld_background.png');
    size = sprite!.originalSize;
  }
}

RayWorldGame class에서 World를 전역변수로 생성해주세요. 그리고 player와 함께 add해주세요
주의할 점은 world의 load를 끝내고 player를 로드해야 합니다. 그렇지 않으면 world component가 player를 덮어버립니다.

final World _world = World();


Future<void> onLoad() async {
  await add(_world);
  await add(_player);
}


이번에는 캐릭터의 이동에 따라서 배경화면도 같이 이동시켜보겠습니다. Flame에서는 이를 간단하게 구현할 수 있습니다. RayWorldGame class의 onLoad에 카메라 설정을 해주겠습니다. player는 world 컴포넌트의 가운데에 위치시켜놓고, player가 움직이는대로 화면도 같이 이동시키도록 했습니다.
  
  Future<void> onLoad() async {
    await add(_world);
    await add(_player);
    _player.position = _world.size / 2;
    camera.followComponent(
      _player,
      worldBounds: Rect.fromLTRB(
        0,
        0,
        _world.size.x,
        _world.size.y,
      ),
    );
  }


11개의 댓글

comment-user-thumbnail
2022년 12월 13일

안녕사세요. 제가 필요한 정보가 있어 도움 받으러 왔는데 기본 프로젝트 링크가 있다고 하는데 안보여서 도움 요청 드립니다. 감사합니다.

  • 우선 starter 아래 링크에서 프로젝트를 다운받아서 실행해주세요.
1개의 답글
comment-user-thumbnail
2023년 1월 4일

보고 따라할 만한 깃헙주소 있을까요?

1개의 답글
comment-user-thumbnail
2023년 3월 10일

안녕하세요. 해당 글 흥미롭게 읽었습니다.
혹시 background 이미지에 대한 저작권 정보를 알 수 있을까요?
opensource 인지 궁금합니다.

1개의 답글
comment-user-thumbnail
2023년 8월 13일

감사합니다. 많은 도움이 되었습니다.

1개의 답글
comment-user-thumbnail
2024년 9월 15일

To her surprise, a series of fortunate spins led to a remarkable winning streak. What began as a casual experiment quickly escalated into a captivating game of chance, with Claire’s modest bets paying off significantly. Her winnings continued https://icecassino1.com/ to accumulate, each spin adding to her growing excitement. By the end of the night, Claire had not only enjoyed an exhilarating experience but had also walked away with a substantial prize. Her story highlights how embracing spontaneity can lead to unexpectedly rewarding adventures.

답글 달기