관리 메뉴

Bull

[Flutter::flame] Flame 엔진을 이용하여 구글의 공룡 게임을 만들어 보자! 본문

Software Framework/Flutter

[Flutter::flame] Flame 엔진을 이용하여 구글의 공룡 게임을 만들어 보자!

Bull_ 2024. 8. 7. 20:33

서론

원래는 테트리스를 GPT를 이용해서 빠르게 만드려다가 생각보다 시간이 걸릴 거 같아서 연습 삼아 구글의 공룡게임을 만들게 되었습니다. flame에 대해서는 거의 모르는 상태입니다.

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}

끽해야 flame을 실행하려면 첫 시작을 위처럼 코드를 작성해야 한다는 정도로 알고 있는데요. 얄팍한 지식이지만 설명을 하면서 어떻게 동작하는 지 설명해보겠습니다.

 

특히 배경에 대해서 완전한 마무리를 지은 것이 아니라서 그거에 대해 양해를 구하고 설명해보겠습니다.

소스코드와 에셋은 제일 아래에 제공해놨으니 참고하세요!

assets

먼저 게임을 만들려면 에셋이 필요합니다. 공룡 게임을 만들기 위해서 공룔과 장애물은 반드시 필요한데요. 배경은 반드시 필요하지 않지만 assets을 추가하는 과정에서 배경이 없으니 밋밋한 느낌이 들어서 배경도 추가하였습니다.

assets 만들기

유니티 같은 경우는 에셋 스토어가 따로 존재하고 플레임은 잘 모르겠지만 유니티 에셋스토어에서 가져와서 사용해도 됩니다. 하지만 전 간단하게 만들고 싶어서 chatGPT를 통해 일단 물어보는 시도를 해봤습니다.

chatGPT로 asset 확보하기

wow! 배경이 투명한 png 파일이었으면 좋았겠지만 어쨌든 리소스 파일 확보는 성공했습니다! chatGPT는 webp 확장자로 이미지를 제공해주기 때문에 webp to png 를 제공해주는 사이트에서 변환을 진행해보겠습니다.

 

변환된 png 파일을 2개로 복사하여서 공룡 사진과 선인장 사진을 나눠주면 됩니다. 그러면 아직 배경색이 남게 되는데요. png파일의 배경을 투명하게 만들어 주는 사이트 또한 존재하네요.

 

잘라낸 공룡 사진과 선인장 사진을 위 사이트에서 변경해보겠습니다.

png 배경 없애기

이런 걸 누끼 딴다고 하더라고요. 그런데 약간의 픽셀이 남았고 오른쪽에 테두리가 약간 잘려나갔지만 그대로 사용해줍니다. 이제 파일이름을 dino.png 와 cactus.png로 만들고 assets/images/ 폴더에 넣도록 하겠습니다.

 

그리고 배경 이미지도 잊지 말아주세요. 같은 방식으로 반들어서 assets/images/ 폴더에 넣어 줍니다.

chatGPT로 asset 확보하기

배경에 픽셀이 깨진 것이 조금 아쉽지만 넘어가겠습니다...

Dino Game

이제 본격적으로 CODE 를 단계적으로 풀어나가며 설명해보겠습니다. 그전에 다음과 같이 asset과 flame 패키지를 yaml 파일을 통해서 등록해주고 파일을 나눠서 만들어보겠습니다.

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.18.0

flutter:
  assets:
    - assets/images/dino.png
    - assets/images/cactus.png
    - assets/images/background.png

파일은 다음과 같이 만들어줍니다.

lib
  ├─ components
  │    ├─ dino.dart
  │    ├─ obstacle_manager.dart
  │    └─ obstacle.dart
  ├─ game
  │    └─ dino_game.dart
  ├─ pages
  │    └─ game_page.dart
  ├─ widgets
  │    └─ game_over_dialog.dart
  └─ main.dart

flame 실행

처음에 이해를 돕기 위해서는 하나의 파일 안에다가 모두 구현하는 것이 좋지만 설명을 위해 리팩터를 먼저 하고 설명을 하겠습니다.

// filename: main.dart

import 'package:flutter/material.dart';
import 'pages/game_page.dart';

void main() async {
  runApp(MaterialApp(
    home: DinoGamePage(),
  ));
}

main 에는 gamePage 를 먼저 렌더링 해줍니다. 여기서는 아직 flame이 사용되지 않았습니다.

// filename: game/dino_game.dart
import 'package:flame/game.dart';

class DinoGame extends FlameGame {
  @override
  void onLoad() {}

  void reset() {}
}

공룡 게임이 시작되는 부분을 정의해줍니다. 여기서 FlameGame을 상속받았습니다. 왜냐하면 앞서 설명할 DinoGamePage 위젯에서 flame을 실행하기 위해서는 FlameGame 클래스를 game 프로퍼티에 등록해주어야 하기 때문입니다.

 

onLoad 메소드는 FlameGame에 메소드를 상속받아서 사용합니다. 이 부분은 게임이 시작될 때 각종 컴포넌트 등을 초기화하고 진행을 하는 부분을 정의합니다. reset 메소드는 게임이 끝났을 때 초기화해주는 역할을 구현합니다. 게임이 끝나는 로직에 따라 이 메소드를 호출합니다.

// filename: pages/game_page.dart

import 'package:flutter/material.dart';
import 'package:flame/game.dart';
import '../game/dino_game.dart';

class DinoGamePage extends StatefulWidget {
  @override
  _DinoGamePageState createState() => _DinoGamePageState();
}

class _DinoGamePageState extends State<DinoGamePage> {
  late DinoGame game;

  @override
  void initState() {
    super.initState();
    game = DinoGame();
  }

  @override
  Widget build(BuildContext context) {
    return GameWidget(game: game);
  }
}

flame은 기본적으로 GameWidget 의 game 프로퍼티를 통해서 FlameGame 클래스를 등록해주어야 합니다. 우리는 이전에 dino_game.dart 에서 DinoGame 클래스가 FlameGame을 상속 받도록 했습니다. 이제 initState 메소드를 통해서 game 변수에 DinoGame 클래스를 초기화 해줍니다. 이 변수를 game 프로퍼티에 등록해줍니다.

flame 기본 화면

아무것도 뜨지 않고 검은화면이 나오면 첫 단계를 완료했습니다.

Background

가장 구현하기 쉬울 거 같으면서도 화면에 따라 조정하기 힘든 배경을 먼저 해보겠습니다. 서론에서 이미 언급한 바 있지만, 우리는 앱을 웹에서 사용할 지 윈도우에서 사용할 지, 모바일에서 사용할 지 정하지 않았습니다. 사실 약간 핑계스러운 이유가 가미된 거지만 배경을 맞추는 데 정해놓은 기준이 없으면 반응형으로 만들기 까다롭기 때문에 켜지면 나오는 그 배경을 사용하는 기준으로 만들겠습니다. (배경에 대해서 여러 시도를 하긴했습니다 ㅠㅠ)

 

우리는 배경을 움직일 겁니다. 제가 고등학생때 즐겨하던 모바일 게임 중 하나로 '표창 키우기'가 있는데요. 이 게임도 보면 알겠지만 캐릭터가 앞으로 가는 것을 묘사하기 위해서 배경을 뒤로 움직입니다. 그래서 배경도 뒤로 움직이는 부분을 구현하기 위해서ParallaxComponent 라는 컴포넌트를 사용하겠습니다. Parallax를 번역하면 "시차" 입니다. 이 컴포넌트는 여러 레이어가 있을 때 각기 다른 속도감을 표현하기 위해 사용된다고 합니다. 자세한 내용은 Flame Components 공식 문서를 참고하면 좋을 것 같네요.

// filename: game/dino_game.dart

import 'package:flame/components.dart';
import 'package:flame/parallax.dart';
@override
Future<void> onLoad() async {
  await images.loadAll(['dino.png', 'cactus.png', 'background.png']);

  final parallaxComponent = await ParallaxComponent.load(
    [ParallaxImageData('background.png')],
    size: size,
    baseVelocity: Vector2(20, 0),
  );
  add(parallaxComponent);
}

onLoad 메소드를 비동기 메소드로 변환해줍니다. 그리고 이후에 사용될 에셋도 미리 images.loadAll를 통해서 등록해주면 됩니다. 이제 배경을 나타내기 위해 ParallaxComponent.load를 통해 정의할 컴포넌트를 가져옵니다. 여기서 ParallaxImageData 를 통해 parallax에 사용될 이미지를 리스트에 넣어줍니다.

 

size는 앱의 크기에 맞게 가져옵니다. 그래서 이후에 실행한 상태에서 화면을 줄였다가 펼치면 검은색 나머지 화면을 볼 수 있습니다. 불편하겠지만 제가 좋은 솔루션을 찾지를 못해서 이 방식으로 사용하겠습니다.

 

baseVelocity 는 컴포넌트의 기본 속도입니다. 컴포넌트가 늘어나고 캐릭터의 속도에 따라 배경의 속도를 조정할 수도 있는 프로퍼티가 있기 때문에 기본 속도라는 프로퍼티가 따로 설정되어 있는 것입니다. 그것까지는 가지 않으니 넘어 가도록 합니다.

 

이제 flame 의 add 메소드를 통해서 컴포넌트 요소를 등록해줍니다.

flagme 배경 등록

움직이는 배경을 보실수 있습니다. 사진의 엣지 부분이 갈라지는 게 표시되지만 넘어가도록 하겠습니다. ㅎㅎ..

Dino components

배경을 그렸으니 주요 인물인 공룡 컴포넌트를 그려보겠습니다. 우선 컴포넌트 파일에 다음과 같은 코드를 작성합니다.

// filename: components/dino.dart

import 'package:flame/components.dart';
import 'package:flame/collisions.dart';

class Dino extends SpriteComponent {
  Dino()
      : super(
          size: Vector2(50, 50),
          position: Vector2(100, 400),
        );

  @override
  Future<void> onLoad() async {
    sprite = await Sprite.load('dino.png');
    }
}

SpriteComponent를 상속 받아 공룡을 그려줄 것입니다. 생성 자를 통해 공룡의 크기와 초기 위치를 초기화해줍니다. 여기서 position은 앱의 왼쪽 위부터 아래 오른쪽 방향으로 값을 증가시키는 점 참고바랍니다. 이 부분도 onLoad 메소드를 통해 공룡을 그려냅니다. Sprite.add로 에셋을 불러옵니다.

// filename: game/dino_game.dart

import '../components/dino.dart';

class DinoGame extends FlameGame {
  late Dino _dino;

  Future<void> onLoad() async {
    (... CODE ...)
    _dino = Dino();
    add(_dino); 
  }

flame 에 공룡 컴포넌트를 추가해줍니다.

flame 공룡 추가

귀여운 공룡 한 마리가 게임 속에 등장했습니다~

Jumping 공룡

점프를 하는 메소드를 구현해보겠습니다.

// filename: components/dino.dart

double gravity = 800; // 중력 가속도
double jumpSpeed = -400; // 점프 속도
double verticalSpeed = 0; // 점프 또는 내려올 때의 속도
bool isJumping = false;

verticalSpeed는 현재 점프를 하든 내려오고 있는 현재에 대한 수직 스피드입니다. 기존에는 아무런 동작을 하고 있지 않기 때문에 0입니다.

// filename: components/dino.dart

void jump() {
  if (!isJumping) {
    isJumping = true;
    verticalSpeed = jumpSpeed;
  }
}

점프 메소드가 호출 되면 jump 상태를 true로 바꾸어 주고 현재 수직 속도를 jumpSpeed로 만들어줍니다. 그러면 -400이었기 때문에 - 뱡향인 위쪽으로 가게 될 것입니다.

@override
void update(double dt) {
  super.update(dt);
  position.y += verticalSpeed * dt;
  verticalSpeed += gravity * dt;

  // 땅에 닿으면 점프 중지
  if (position.y >= 400) {
    position.y = 400;
    isJumping = false;
    verticalSpeed = 0;
  }
}

현재 점프가 호출된 상태여서 수직 속도는 - 방향인 위쪽으로 가고 있습니다. 이 상태에서 update 메소드를 오버라이드하여 변경해줍니다. update 메소드는 flame의 매 프레임마다 화면을 업데이트 해주는 메소드입니다. 여기서 dt(delta time) 매 프레임 간격 시간을 나타냅니다.

 

이제 postion.y 에 (수직 속도 * dt) 를 더해줍니다. 현재 수직 속도는 - 방향인 위쪽이기 때문에 처음에는 위쪽으로 갑니다. 중력 가속도는 + 방향인 아래쪽이었죠? 수직 속도가 현재 음수기 때문에 언젠가 0이 되는 시점이 오게됩니다. 왜냐하면 verticalSpeed += gravity * dt; 때문이죠. 이제 예를 들어 position.y 가 300 정도 됐을 때 수직속도가 0이 되었다고 합시다. 중력 가속도는 여전히 양수를 더해주고 있기 때문에 300 에서 다시 301, 302, ... position.y 가 올라가기 시작합니다. 숫자가 올라간다는 것은 화면에서는 아래로 내려가는 것을 의미합니다.

 

원래 위치였던 400으로 왔습니다. 이제 약간을 오차를 줄이기 위해 position.y를 400으로 만들어주고 jump 상태를 false로 만들어줍니다. 그리고 수직 속도는 다시 0이 됩니다.

Key Event 입력 받기

점프 메소드를 구현해 주었습니다. 일반적으로 모바일에서 하면 GestureDetector와 같은 메소드로 쉽게 구현해도 됩니다. 하지만 웹이나 윈도우 등에서 실행하려면 키보드가 훨씬 편합니다. 따라서 저는 스페이스바를 눌렀을 때 점프를 하도록 하는 이벤트를 등록해보도록 하겠습니다.

 

키보드는 입력 받는 이벤트를 등록하려면 제가 작성한 포스트를 보는 것도 추천드립니다. 이미 작성한 포스트가 있으므로 간략하게 설명만 하고 넘어가겠습니다.
https://codcost.tistory.com/214

 

[Flutter] keyboard 입력받기 Web, Window, ... (Level 1 : 구현)

서론제가 테트리스를 즐겨해서 시간을 내가지고 Flutter의 Flame을 연습할겸, 테트리스를 직접 구현하면 어떨까~~ㅎㅎ하는 마음에 Flame으로 간단하게 틀만 만들고 있었는데요. 가장 기본이 되는 키

codcost.tistory.com

 

// filename: pages/game_page.dart
// class _DinoGamePageState extends State<DinoGamePage>

final FocusNode _focusNode = FocusNode();

스페이스 바를 입력받기 전에 앱이 포커싱 된 상태인지를 알아야 합니다. FocusNode를 통해서 인스턴스를 생성해주고 포커싱을 추적해보겠습니다.

// filename: pages/game_page.dart

@override
void dispose() {
  _focusNode.dispose();
  super.dispose();
}

소멸자로 해제하는 거 잊지 마시기 바랍니다.

// filename: pages/game_page.dart

@override
Widget build(BuildContext context) {
  return Focus(
    autofocus: true,
    focusNode: _focusNode,
    onKeyEvent: _handleKeyEvent,
    child: GestureDetector(
      onTap: () {
        FocusScope.of(context).requestFocus(_focusNode);
      },
      child: GameWidget(game: game),
    ),
  );
}

Focus 위젯으로 포커싱을 관리할 수있습니다. GestureDetector 위젯으로 터치 혹은 클릭이 되면 포커싱을 요청합니다. focusNode 로 포커싱을 감지할 수 있습니다. 이제 onKeyEvent 프로퍼티로 핸들을 관리하는 메소드를 구현해야합니다.

// filename: pages/game_page.dart

KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
  setState(() {
    if (event is KeyDownEvent &&
        event.logicalKey == LogicalKeyboardKey.space) {
      game.dino.jump();
    }
  });
  return KeyEventResult.handled;
}

onKeyEvent 프로퍼티는 KeyEventResult 의 enum 값들 중 하나로 나타내야합니다. handled 값을 통해 동작이 되도록합니다. 만약 키를 눌렀고 키가 스페이스 바라면 game.dino.jump 메소드를 호출합니다. 중간에 dino가 등장했는데요. DinoGame 클래스에서 생성한 인스턴스인 dino로 메소드를 호출해야 하기 때문입니다. 이제 마지막으로 다음 코드를 작성해줍니다.

// filename: game/dino_game.dart

Dino get dino => _dino;

getter 를 사용하여 dino에 접근할 수 있습니다. 이제 스페이스를 누르면 공룡이 점프를 하는 모습을 볼 수 있습니다.

flame 점핑 공룡

Coming cactus

우리는 이제 장애물로 선인장을 그려줄 것입니다. 구글 공룡게임을 보면 장애물이 공룡쪽으로 다가오는 것처럼 애니메이션 되는 것이 상기되시나요? 랜덤한 위치에 여러 개의 장애물 나옵니다. 그러면 이 선인장을 만들어내는 "관리자"도 필요하겠죠? 하지만 우선 선인장 하나를 공룡쪽으로 오도록 만들어 보겠습니다.

// filename: components/obstacle.dart

import 'package:flame/components.dart';
import 'package:flame/game.dart';

class Obstacle extends SpriteComponent {
  Obstacle(Vector2 position)
      : super(
          size: Vector2(50, 50),
          position: position,
        );

  @override
  Future<void> onLoad() async {
    sprite = await Sprite.load('cactus.png');

  }

  @override
  void update(double dt) {
    super.update(dt);
    position.x -= 200 * dt;

    if (position.x < -size.x) {
      removeFromParent();
    }
  }
}

이전에 dino를 만들어 내는 코드랑 비슷하죠? 여기서 position을 파라미터를 통해 전달 받는 것으로 나타냈는데요. 장애물 매니저를 통해서 장애물을 생성할 것이기 때문입니다. 그리고 매 프레임마다 update 메소드를 통해서 선인장의 x축을 공룡쪽으로 오게 만들어줍니다. 그리고 화면의 x축보다 작아지면 removeFromParent 메소드를 호출하여 그 개체를 부모 컴포넌트로 부터 자식 목록에서 제거됩니다. 그러면 더 이상 게임 루프에서 업데이트되지 않기 때문에 더이상 호출되지 않습니다.

// filename: components/obstacle_manager.dart

import 'package:flame/components.dart';
import 'package:flame/game.dart';
import '../game/dino_game.dart';
import 'obstacle.dart';

class ObstacleManager extends Component with HasGameRef<DinoGame> {
  int limit = 0;

  @override
  void update(double dt) {
    super.update(dt);

    final position = Vector2(1000, 400);
    final obstacle = Obstacle(position);
    if (limit < 1) {
      gameRef.add(obstacle);
      limit++;
    }
  }

  void reset() {}
}

우선 선인장을 하나만 생성해보도록 하겠습니다. 여기서 update 메소드를 통해서 생성되므로 limit 변수를 통해서 개수를 제한해줍니다. 그렇지 않으면 매 프레임마다 선인장이 만들어지기 때문에 원하는 로직을 구현할 수 없습니다.

 

HasGameRef<DinoGame> 믹스인이 있습니다. 여기서 Obstacle 클래스를 DinoGame 클래스에서 컴포넌트를 추가하는 행위를 하지 않기 때문에 참조를 통해서 컴포넌트를 추가할 수 있도록 도와줍니다.

// filename: game/dino_game.dart
import '../components/obstacle_manager.dart;

// class DinoGame extends FlameGame
late ObstacleManager _obstacleManager;

Future<void> onLoad() async {
  _obstacleManager = ObstacleManager();
  add(_obstacleManager);
}

이전과 마찬가지로 DinoGame클래스에서 ObstacleManager 컴포넌트를 추가해줍니다.

flame coming 선인장

선인장 하나가 공룡쪽으로 오는 애니메이션을 구현했습니다!

Managing Cactus

선인장 하나로는 안됩니다. 이제 여러 개를 소환해볼까요? 선인장 각각의 개체에는 간격이 필요합니다. 하지만 일정한 간격이면 재미가 없겠죠. 일정한 간격에서 랜덤한 간격을 추가해줄 것입니다.

// filename: components/obstacle_manager.dart
import 'dart:math';

// class ObstacleManager extends Component with HasGameRef<DinoGame> 
final List<Obstacle> obstacles = [];
final Random random = Random();
double nextObstacleDistance = 0;

이전 limit 변수와 update 메소드에 구현해놨던 선인장 1개 소환 로직은 없애줍니다.

 

Obstacle을 리스트에 저장해주기 위해서 리스트를 선언합니다. 각 소환된 선인장의 간격을 랜덤하게 주어야하기 때문에 dart:math 패키지를 임포트 해주고 nextObstacleDistance 변수를 통해서 각 사이의 간격을 저장해줍니다.

// filename: components/obstacle_manager.dart

@override
void update(double dt) {
  super.update(dt);
  if (obstacles.isEmpty ||
      (gameRef.size.x - obstacles.last.position.x) > nextObstacleDistance) {

  }
}

이제 update 메소드를 통해서 선인장을 생성할 것 입니다. gameRef.size.x 는 앱의 가로 길이입니다. 즉 오른쪽 끝 부분이 됩니다. 만약 obstacles 리스트에서 마지막 선인장 개체의 x 값과 오른쪽 끝부분의 간격이 nextObstacleDistance 보다 커지면 선인장을 생성합니다.

// filename: components/obstacle_manager.dart

@override
void update(double dt) {
  super.update(dt);

  if (obstacles.isEmpty ||
      (gameRef.size.x - obstacles.last.position.x) > nextObstacleDistance) {
    final position = Vector2(gameRef.size.x , 400);
    final obstacle = Obstacle(position);
    obstacles.add(obstacle);
    gameRef.add(obstacle);

    nextObstacleDistance = random.nextDouble()* 200 +300;
  }

  obstacles.removeWhere((obstacle) => obstacle.parent == null);
}

이전과 같이 gameRef.add(obstacle); 을 통해 컴포넌트 요소를 추가해주었는데 여기에 더해서 obstacles 의 리스트에도 추가해줍니다. nextObstacleDistance 변수는 매 생성마다 300에서 500의 값으로 랜덤하게 만들어줍니다.

 

화면 밖으로 나간 개체는 부모를 제거해주었기 때문에 obstacles에서도 제거주어야 합니다. removeWhere 메소드를 통해서 간단하게 구현합니다.

flame manging cactus

자세히 보이진 않지만 미세하게 랜덤한 간격으로 선인장이 소환됩니다!

Collision

공룡과 선인장의 모션 구현을 모두 마쳤습니다. 이제 대망의 게임오버 로직을 구현해봐야겠죠?

우선 각 스프라이트에 히트박스를 그리고 직접 눈으로 확인해보겠습니다.

// filename: components/dino.dart
import 'package:flame/collisions.dart';

// class Dino extends SpriteComponent with CollisionCallbacks 
@override
Future<void> onLoad() async {
  sprite = await Sprite.load('dino.png');
  add(RectangleHitbox(
    size: Vector2(30, 30),
    position: Vector2(10, 10),
  ));
  debugMode = true;
}
// filename: components/obstacle.dart
import 'package:flame/collisions.dart';

// class Obstacle extends SpriteComponent with CollisionCallbacks
@override
Future<void> onLoad() async {
  sprite = await Sprite.load('cactus.png');
  add(RectangleHitbox(
    size: Vector2(30, 30),
    position: Vector2(10, 10),
  ));
  debugMode = true;
}

dino.dart, obstacle.dart 파일에서 flame/collisions.dart 패키지를 임포트 해주시고 클래스에 CollisionCallbacks을 믹스인 해줍니다. 그리고 RectangleHitbox 요소를 컴포넌트에 추가해줍니다.

 

RectangleHitbox 는 충돌 감지를 위한 히트 박스 메소드입니다. 이 메소드를 통해서 각 히트박스끼리 충돌을 감지할 수 있습니다.

 

debugMode = true; 는 필수 요소는 아닙니다. 하지만 히트박스 확인을 위해서 임시적으로 적용합니다.

flame HitBox

히트박스를 확인할 수 있습니다! 분홍색은 스프라이트 크기, 노란색이 히트박스입니다.

 

이제 서로 부딪혔는 지 알기 위한 메소드를 구현해야 합니다. 공룡이 선인장에 닿았을 때 게임이 종료되어야 하겠죠. 따라서 Dino 클래스에 메소드를 구현하겠습니다.

// filename: components/dino.dart
import '../game/dino_game.dart';

// class Dino extends SpriteComponent with CollisionCallbacks, HasGameRef<DinoGame>

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
  if (other is Obstacle) {
    gameRef.pauseEngine();
  }
  super.onCollision(intersectionPoints, other);
}
// filename: game/dino_game.dart
// class DinoGame extends FlameGame with HasCollisionDetection 

DinoHasGameRef<DinoGame> 믹스인을 적용합니다. 그리고 onCollision 를 상속받아서 메소드를 구현해줍니다. 여기서 intersectionPoints 과 other 의 관계는 공룡과 선인장의 관계로만 생각해둡시다. other은 선인장 외에 다른 히트박스여도 되겠지만 여기서는 두 개 밖에 없어서 선인장으로만 취급합니다. 그리고 other 이 Obstacle(선인장) 이면 gameRef.pauseEngine() 메소드를 통해서 flame 엔진을 멈춰줍니다. gameRefDinoGame 클래스를 참조합니다.

 

상호간의 충돌을 DinoGame에서 감지할 수 있도록 HasCollisionDetection 믹스인을 적용시켜주어야 합니다.

flame collision pause

선인장과 부딪히면 게임이 멈추게 됩니다. 이제 끝이 보이네요!

GameOver Dialog

다이얼로그 위젯은 디자인외에 flame을 공부하기에는 크게 따라해볼 작업량이 없기 때문에 별도 설명없이 코드만 제공하겠습니다. 하지만 다이얼로그를 나타내기 위해 작업해야할 로직은 따라해볼 것 입니다.

// filename: widgets/game_over_dialog.dart

import 'package:flutter/material.dart';

class GameOverDialog extends StatelessWidget {
  final VoidCallback onReset;

  const GameOverDialog({Key? key, required this.onReset}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return SimpleDialog(
      backgroundColor: Colors.grey.shade800,
      contentPadding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 32.0),
      titlePadding: const EdgeInsets.all(24.0),
      title: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          const Text(
            'Game Over',
            textAlign: TextAlign.center,
            style: TextStyle(color: Colors.white),
          ),
          Align(
            alignment: Alignment.topRight,
            child: InkWell(
              borderRadius: BorderRadius.circular(4),
              onTap: () => Navigator.pop(context),
              child: const Padding(
                padding: EdgeInsets.all(8.0),
                child: Icon(
                  Icons.close,
                  size: 16,
                  color: Colors.white,
                ),
              ),
            ),
          ),
        ],
      ),
      children: [
        const Padding(
          padding: EdgeInsets.fromLTRB(30.0, 0.0, 30.0, 40.0),
          child: Text(
            'Do you want to restart the game?',
            style: TextStyle(color: Colors.white, fontSize: 16),
          ),
        ),
        Padding(
          padding: const EdgeInsets.symmetric(horizontal: 32),
          child: OutlinedButton(
            onPressed: onReset,
            style: OutlinedButton.styleFrom(
              overlayColor: Colors.black,
              side: BorderSide(color: Color.fromARGB(255, 192, 255, 75)),
            ),
            child: const Text(
              'Restart',
              style: TextStyle(
                color: Colors.white,
              ),
            ),
          ),
        ),
      ],
    );
  }
}

특이 사항으로 다이얼로그에 콜백함수를 붙여줍니다. Game Over가 되고 Restart가 눌러진다면 reset을 해주는 동작을 구현해야하기 때문입니다.

final VoidCallback onReset;
const GameOverDialog({Key? key, required this.onReset}) : super(key: key);
(... CODE ...)
child: OutlinedButton(
  onPressed: onReset,
),

이제 game_page.dart 에서 다이얼로그를 띄우는 메소드를 구현해보겠습니다.

// filename: pages/game_page.dart
import '../widgets/game_over_dialog.dart';

void _showGameOverDialog() {
  showDialog(
    context: context,
    barrierDismissible: false,
    builder: (BuildContext context) {
      return GameOverDialog(
        onReset: () {
          Navigator.of(context).pop();
          game.reset();
          FocusScope.of(context).requestFocus(_focusNode);
        },
      );
    },
  );
}

DinoGame의 클래스에서 아직 reset 메소드는 구현하지 않았습니다. 이후에 구현할 것이니 그대로 넣어둡니다.

이제 onCollision 메소드에서 다이얼로그가 나타나도록 구현할 것입니다. 이를 위해서 콜백을 사용합니다.

// filename: game/dino_game.dart

late Function onGameOver;
DinoGame({required this.onGameOver});

콜백을 추가해줍니다~

// filename: pages/game_page.dart

void initState() {
  super.initState();
  game = DinoGame(onGameOver: _showGameOverDialog);
}

required 를 사용했기 때문에 프로퍼티로 이전에 정의헀던 _showGameOverDialog 메소드를 전달해줍니다. 이제 onCollision 메소드가 있는 Dino 클래스에서 어떻게 실행할까요? 맞습니다. 믹스인 HasGameRef<DinoGame>을 통해서 DinoGame 클래스에 참조가 가능합니다! 이제 gameRef.onGameOver();로 콜백함수를 실행할 수 있겠군요!

// filename: components/dino.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
  if (other is Obstacle) {
    gameRef.pauseEngine();
    gameRef.onGameOver();
  }
  super.onCollision(intersectionPoints, other);
}

flame GameOver dialog

Restart

위의 사진과 같이 restart를 눌러도 게임이 아직 멈춰있는 상태입니다. 이제 다이얼로그를 통해 restart를 누르면 다시 시작하도록 만들어보겠습니다.

// filename: game/dino_game.dart

void reset() {
  _dino.reset();
  _obstacleManager.reset();
  resumeEngine();
}

아직 DinoObsctacleManager의 reset 메소드를 구현하지 않았지만 먼저 DinoGame에서 리셋시켜줍니다. resumeEngine 메소드는 중지된 게임 루프를 다시 재개시켜줍니다.

// filename: components/dino.dart

void reset() {
  position = Vector2(100, 400);
  isJumping = false;
  verticalSpeed = 0;
}

Dino 클래스입니다. 포지션을 움직이진 않았지만 점프 상태일 수도 있으므로 원래 위치로 돌려줍니다.

// filename: components/obstacle_manager.dart

void reset() {
  for (final obstacle in obstacles) {
    obstacle.removeFromParent();
  }
  obstacles.clear();
  nextObstacleDistance = 0;
}

ObstacleManager 클래스입니다. 각 선인장의 부모를 지워주고 리스트를 비워줍니다. nextObstacleDistance는 0으로 초기화합니다.

flame restart

후기

이 flame 엔진을 가볍게 배워볼려고 chatGPT한테 뜯어냈지만 우연치 않은 자랑으로 인해 이 게임에 궁금해하신 분이 1분이라도 있어서 글도 연습할겸 포스팅을 작성해보았습니다. 제가 이 flame 코드를 모른 채 작성했기 때문에 전문성이 많이 떨어진다는 점 양해바랍니다. 그리고 특히 배경은 움직이면 공룡과 선인장의 position도 변하는데 앱의 창에 fit 되도록 하는 것을 결국 못 찾아서 적절히 조절해서 사용하도록 만들었습니다. 이 부분에 시간을 쏟기에는 제 시간이 한정적이라 flame의 핵심만 알려주고자 포기한 부분입니다...

 

코드 만드는데 1시간, 이해하는데 2시간, 포스팅 작성하는데 7시간 ㅋㅋ

 

지적은 언제나 환영입니다. 즐겁게 봐주셔서 감사합니다.

소스 코드와 asset

https://github.com/Chun-Bae/DinoGame

 

GitHub - Chun-Bae/DinoGame: game of avoiding dino

game of avoiding dino. Contribute to Chun-Bae/DinoGame development by creating an account on GitHub.

github.com

images.zip
1.47MB