Flutter와 Flame을 사용한 게임 빌드

1. 소개

Flutter와 Flame으로 플랫폼 게임을 빌드하는 방법을 알아보세요. Doodle Jump에서 영감을 얻은 Doodle Dash 게임에서는 Dash(Flutter 마스코트)나 Dash의 가장 친한 친구 Sparky(Firebase 마스코트)로 플레이하고 플랫폼을 점프하여 최대한 높이 도달하려고 합니다.

학습할 내용

  • Flutter에서 크로스 플랫폼 게임을 빌드하는 방법
  • Flame 게임 루프의 일부로 렌더링하고 업데이트할 수 있는 재사용 가능한 게임 구성요소를 만드는 방법
  • 게임 물리학을 통해 캐릭터(스프라이트라고 함)의 움직임을 제어하고 애니메이션 처리하는 방법
  • 충돌 감지를 추가하고 관리하는 방법
  • 게임 컨트롤로 키보드와 터치 입력을 추가하는 방법

기본 요건

이 Codelab에서는 Flutter를 사용한 경험이 있다고 가정합니다. 경험이 없다면 첫 번째 Flutter 앱 Codelab을 통해 기본사항을 배울 수 있습니다.

빌드할 항목

이 Codelab에서는 Flutter 마스코트 Dash나 Firebase 마스코트 Sparky가 나오는 플랫폼 게임인 Doodle Dash를 빌드하는 방법을 설명합니다(이 Codelab의 나머지 부분에서는 Dash를 언급하지만 단계는 Sparky에도 적용됨). 게임에 포함될 기능은 다음과 같습니다.

  • 수평 및 수직으로 이동할 수 있는 스프라이트
  • 무작위로 생성되는 플랫폼
  • 스프라이트를 끌어내리는 중력 효과
  • 게임 메뉴
  • 일시중지, 다시 재생과 같은 인게임 컨트롤
  • 점수를 기록하는 기능

게임 플레이

Doodle Dash는 Dash를 좌우로 움직이며 플랫폼에서 점프하고, 파워업을 사용하여 게임 전반에 걸쳐 능력을 향상시키는 방식으로 플레이합니다. 초기 난이도(1~5)를 선택하고 Start를 클릭하여 게임을 시작합니다.

d1e75aa0e05c526.gif

난이도

게임에는 다섯 가지 단계가 있습니다. 각 단계(1단계 후)에서는 새로운 기능이 잠금 해제됩니다.

  • 1단계(기본값): 이 단계에서는 NormalPlatformSpringBoard 플랫폼이 생성됩니다. 생성된 모든 플랫폼은 움직이는 플랫폼일 가능성이 20%입니다.
  • 2단계(점수 20점 이상): 한 번만 점프할 수 있는 BrokenPlatform이 추가됩니다.
  • 3단계(점수 40점 이상): NooglerHat 파워업이 잠금 해제됩니다. 이 특수 플랫폼은 5초간 지속되며 Dash의 점프 능력을 보통 속도의 2.5배로 증가시킵니다. Dash는 또한 멋진 누글러 모자도 이 5초 동안 쓰게 됩니다.
  • 4단계(점수 80점 이상): Rocket 파워업이 잠금 해제됩니다. 로켓으로 표현되는 이 특수 플랫폼은 Dash를 천하무적으로 만듭니다. 또한 Dash의 점프 능력을 보통 속도의 3.5배로 늘립니다.
  • 5단계(점수 100점 이상): Enemy 플랫폼이 잠금 해제됩니다. Dash가 적과 충돌하면 게임이 자동으로 종료됩니다.

단계별 플랫폼 유형

1단계(기본값)

NormalPlatform

SpringBoard

2단계(점수 20점 이상)

3단계(점수 40점 이상)

4단계(점수 80점 이상)

5단계(점수 100점 이상)

BrokenPlatform

NooglerHat

Rocket

Enemy

게임에서 지기

두 가지 방법으로 게임에서 질 수 있습니다.

  • Dash가 화면 하단 아래로 떨어집니다.
  • Dash가 적과 충돌합니다(5단계에서 생성된 적).

파워업

파워업은 점프 속도를 높이거나 적에게 '천하무적'이 되도록 하는 등 캐릭터의 플레이 능력을 향상합니다. Doodle Dash에는 파워업 옵션이 두 가지 있습니다. 파워업은 한 번에 하나만 활성화됩니다.

  • 누글러 모자 파워업은 Dash의 점프 능력을 보통 점프 높이의 2.5배로 증가시킵니다. 또한 파워업하는 동안 누글러 모자를 쓰게 됩니다.
  • 로켓 파워업은 Dash를 적 플랫폼에 대해 천하무적으로 만들고(적과 충돌해도 아무런 영향을 받지 않음) 점프 능력을 보통 점프 높이의 3.5배로 증가시킵니다. Dash는 중력이 속도를 압도할 때까지 로켓을 타고 날다 플랫폼에 착륙합니다.

2. Codelab 시작 코드 가져오기

a3c16fc17be25f6c.pngGitHub에서 프로젝트의 초기 버전을 다운로드하세요.

  1. 명령줄에서 GitHub 저장소flutter-codelabs 디렉터리로 클론합니다.
git clone https://github.com/flutter/codelabs.git flutter-codelabs

이 Codelab의 코드는 flutter-codelabs/flame-building-doodle-dash 디렉터리에 있습니다. 이 디렉터리에는 Codelab 각 단계에서 완성된 프로젝트 코드가 포함되어 있습니다.

a3c16fc17be25f6c.png시작 앱 가져오기

  • flutter-codelabs/flame-building-doodle-dash/step_02 디렉터리를 원하는 IDE로 가져옵니다.

a3c16fc17be25f6c.png패키지 설치

  • Flame과 같은 필요한 모든 패키지는 프로젝트 pubspec.yaml 파일에 이미 추가되어 있습니다. IDE에서 자동으로 종속 항목이 설치되지 않으면 명령줄 터미널을 열고 Flutter 프로젝트 루트에서 다음 명령어를 실행하여 프로젝트 종속 항목을 가져옵니다.
flutter pub get

Flutter 개발 환경 설정

이 Codelab을 완료하려면 다음 항목이 필요합니다.

3. 코드 둘러보기

이제 코드를 살펴보겠습니다.

FlameGame을 확장하는 DoodleDash 게임이 포함된 lib/game/doodle_dash.dart 파일을 검토합니다. Flame의 가장 기본적인 구성요소인 FlameGame 인스턴스(Flutter Scaffold와 유사)에 구성요소를 등록하면 등록된 모든 구성요소를 게임 플레이 중에 렌더링하고 업데이트합니다. 이를 게임의 중추 신경계로 생각하면 됩니다.

구성요소란 무엇인가요? Flutter 앱이 Widgets으로 구성되는 방식과 마찬가지로 FlameGame은 게임을 구성하는 모든 기본 요소인 Components로 구성됩니다. Flutter 위젯과 마찬가지로 Components도 하위 구성요소를 보유할 수 있습니다. 캐릭터의 스프라이트, 게임 배경, 새 게임 구성요소(예: 적)를 생성하는 객체가 모두 구성요소입니다. 실제로 FlameGame 자체Component입니다. Flame은 이를 Flame 구성요소 시스템이라고 합니다.

구성요소는 추상 Component 클래스에서 상속받습니다. Component의 추상 메서드를 구현하여 FlameGame 클래스의 메커니즘을 만듭니다. 예를 들어 DoodleDash 전반에 걸쳐 구현된 다음 메서드를 자주 볼 수 있습니다.

  • onLoad: 구성요소를 비동기식으로 초기화합니다(Flutter의 initState 메서드와 유사).
  • update: 게임 루프의 각 틱으로 구성요소를 업데이트합니다(Flutter의 build 메서드와 유사).

또한 add 메서드는 Flame 엔진에 구성요소를 등록합니다.

예를 들어 lib/game/world.dart 파일에는 ParallaxComponent를 확장하여 게임 배경을 렌더링하는 World 클래스가 포함되어 있습니다. 이 클래스는 이미지 애셋 목록을 가져와서 레이어로 렌더링합니다. 그러면 각 레이어가 다른 속도로 이동하여 좀 더 사실적으로 보일 수 있습니다. DoodleDash 클래스는 ParallaxComponent 인스턴스를 포함하며 DoodleDash onLoad 메서드에서 게임에 추가합니다.

lib/game/world.dart

class World extends ParallaxComponent<DoodleDash> {
 @override
 Future<void> onLoad() async {
   parallax = await gameRef.loadParallax(
     [
       ParallaxImageData('game/background/06_Background_Solid.png'),
       ParallaxImageData('game/background/05_Background_Small_Stars.png'),
       ParallaxImageData('game/background/04_Background_Big_Stars.png'),
       ParallaxImageData('game/background/02_Background_Orbs.png'),
       ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
       ParallaxImageData('game/background/01_Background_Squiggles.png'),
     ],
     fill: LayerFill.width,
     repeat: ImageRepeat.repeat,
     baseVelocity: Vector2(0, -5),
     velocityMultiplierDelta: Vector2(0, 1.2),
   );
 }
}

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
   with HasKeyboardHandlerComponents, HasCollisionDetection {
 ...
 final World _world = World();
 ...

 @override
 Future<void> onLoad() async {
   await add(_world);
   ...
 }
}

상태 관리

lib/game/managers 디렉터리에는 Doodle Dash의 상태 관리를 처리하는 세 개의 파일(game_manager.dart, object_manager.dart, level_manager.dart)이 포함되어 있습니다.

GameManager 클래스(game_manager.dart에 있음)는 전반적인 게임 상태와 점수를 추적합니다.

ObjectManager 클래스(object_manager.dart에 있음)는 플랫폼 생성 및 삭제 위치와 시기를 관리합니다. 나중에 이 클래스에 추가하게 됩니다.

마지막으로 LevelManager 클래스(level_manager.dart에 있음)는 플레이어가 레벨업될 때 관련 게임 구성과 함께 게임의 난이도를 관리합니다. 게임의 난이도는 다섯 가지입니다. 플레이어는 점수 기준 중 하나에 도달하면 다음 단계로 넘어갑니다. 단계가 올라갈 때마다 난이도가 올라가며 Dash는 더 높이 점프해야 합니다. 게임 내내 중력이 일정하므로 더 먼 거리를 고려하여 점프 속도가 점차 증가합니다.

플레이어의 점수는 플레이어가 플랫폼을 통과할 때마다 증가합니다. 플레이어가 특정 포인트 기준을 달성하면 게임이 레벨업되고 새로운 특수 플랫폼이 잠금 해제되어 게임에 재미와 어려움이 더해집니다.

4. 게임에 플레이어 추가

이 단계에서는 게임에 캐릭터(여기서는 Dash)를 추가합니다. 플레이어는 캐릭터를 제어하고 모든 로직은 player.dart 파일의 Player 클래스에 있습니다. Player 클래스는 맞춤 로직을 구현하기 위해 재정의하는 추상 메서드가 포함된 Flame의 SpriteGroupComponent 클래스를 확장합니다. 여기에는 애셋 및 스프라이트 로드, 플레이어 배치(수평 및 수직으로), 충돌 감지 구성, 사용자 입력 수용이 포함됩니다.

애셋 로드

Dash는 여러 버전의 캐릭터와 파워업을 나타내는 다양한 스프라이트로 표시됩니다. 예를 들어 다음 아이콘은 Dash와 Sparky가 정면과 왼쪽, 오른쪽을 바라보는 모습을 보여줍니다.

Flame의 SpriteGroupComponent를 사용하면 _loadCharacterSprites 메서드에서 확인하게 될 sprites 속성으로 여러 스프라이트 상태를 관리할 수 있습니다.

a3c16fc17be25f6c.pngPlayer 클래스에서 다음 줄을 onLoad 메서드에 추가하여 스프라이트 애셋을 로드하고 Player의 스프라이트 상태를 앞을 바라보도록 설정합니다.

lib/game/sprites/player.dart

@override
Future<void> onLoad() async {
  await super.onLoad();

  await _loadCharacterSprites();                                      // Add this line
  current = PlayerState.center;                                       // Add this line
}

_loadCharacterSprites에서 스프라이트와 애셋을 로드하는 코드를 검토합니다. 이 코드는 onLoad 메서드에서 직접 구현될 수 있지만 별도의 메서드에 배치하면 소스 코드가 정리되어 더 읽기 쉬워집니다. 이 메서드는 아래와 같이 로드된 스프라이트 애셋과 각 캐릭터 상태를 페어링하는 sprites 속성에 맵을 할당합니다.

lib/game/sprites/player.dart

Future<void> _loadCharacterSprites() async {
   final left = await gameRef.loadSprite('game/${character.name}_left.png');
   final right = await gameRef.loadSprite('game/${character.name}_right.png');
   final center =
       await gameRef.loadSprite('game/${character.name}_center.png');
   final rocket = await gameRef.loadSprite('game/rocket_4.png');
   final nooglerCenter =
       await gameRef.loadSprite('game/${character.name}_hat_center.png');
   final nooglerLeft =
       await gameRef.loadSprite('game/${character.name}_hat_left.png');
   final nooglerRight =
       await gameRef.loadSprite('game/${character.name}_hat_right.png');

   sprites = <PlayerState, Sprite>{
     PlayerState.left: left,
     PlayerState.right: right,
     PlayerState.center: center,
     PlayerState.rocket: rocket,
     PlayerState.nooglerCenter: nooglerCenter,
     PlayerState.nooglerLeft: nooglerLeft,
     PlayerState.nooglerRight: nooglerRight,
   };
 }

플레이어 구성요소 업데이트

Flame은 이벤트 루프의 틱마다 한 번 구성요소의 update 메서드를 호출하여 변경된 각 게임 구성요소를 다시 그립니다(Flutter의 build 메서드와 유사). 그런 다음 Player 클래스의 update 메서드에 로직을 추가하여 캐릭터를 화면에 배치합니다.

a3c16fc17be25f6c.png다음 코드를 Playerupdate 메서드에 추가하여 캐릭터의 현재 속도와 위치를 계산합니다.

lib/game/sprites/player.dart

 void update(double dt) {
                                                             // Add lines from here...
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;                              // ... to here.

   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {                  // Add lines from here...
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }                                                                   // ... to here.

   // Core gameplay: Add gravity

   position += _velocity * dt;                                       // Add this line
   super.update(dt);
 }

플레이어를 움직이기 전에 update 메서드는 게임이 초기 상태(게임이 처음 로드될 때) 또는 게임 종료 상태 등 플레이어가 움직이면 안 되는 플레이 불가능한 상태에 있지 않은지 확인합니다.

게임이 플레이 가능한 상태에 있으면 Dash의 위치가 다음 코드와 같이 방정식 new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick)을 사용하여 계산됩니다.

 position += _velocity * dt

Doodle Dash를 빌드할 때의 또 다른 주요 측면은 무한 측면 경계를 포함하는 것입니다. 그러면 Dash가 화면의 왼쪽 가장자리에서 점프하여 오른쪽으로 다시 화면에 들어올 수 있습니다(그 반대도로 가능).

7068325e8b2f35fc.gif

이는 Dash의 위치가 화면 왼쪽 또는 오른쪽 가장자리를 넘어갔는지 확인하고 넘어갔다면 반대쪽 가장자리로 위치를 재배치하여 구현합니다.

주요 이벤트

초기에는 Doodle Dash가 웹과 데스크톱에서 실행되므로 플레이어가 캐릭터의 이동을 제어할 수 있도록 키보드 입력을 지원해야 합니다. onKeyEvent 메서드를 사용하면 Player 구성요소에서 화살표 키 누름을 인식하여 Dash가 왼쪽이나 오른쪽을 보고 그쪽으로 이동해야 하는지 결정할 수 있습니다.

Dash는 왼쪽으로 이동할 때 왼쪽을 바라봅니다.

Dash는 오른쪽으로 이동할 때 오른쪽을 바라봅니다.

이제 수평으로 이동하는 Dash의 능력을 구현합니다(_hAxisInput 변수에 정의되어 있음). 또한 이동하는 방향으로 Dash가 바라보도록 만듭니다.

a3c16fc17be25f6c.pngPlayer 클래스의 moveLeftmoveRight 메서드를 수정하여 Dash의 현재 방향을 정의합니다.

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;

   current = PlayerState.left;                                      // Add this line

   _hAxisInput += movingLeftInput;                                  // Add this line

 }

 void moveRight() {
   _hAxisInput = 0;

   current = PlayerState.right;                                     // Add this line

   _hAxisInput += movingRightInput;                                 // Add this line

 }

a3c16fc17be25f6c.png왼쪽 화살표나 오른쪽 화살표 키를 누를 때 각각 moveLeft 또는 moveRight 메서드를 호출하도록 Player 클래스의 onKeyEvent 메서드를 수정합니다.

lib/game/sprites/player.dart

@override
 bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
   _hAxisInput = 0;

                                                             // Add lines from here...
   if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
     moveLeft();
   }

   if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
     moveRight();
   }                                                                   // ... to here.

   // During development, it's useful to "cheat"
   if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
     // jump();
   }

   return true;
 }

이제 Player 클래스가 작동하므로 Doodle Dash 게임에서 사용할 수 있습니다.

a3c16fc17be25f6c.pngDoodleDash 파일에서 sprites.dart를 가져와 Player 클래스를 사용합니다.

lib/game/doodle_dash.dart

import 'sprites/sprites.dart';                                       // Add this line

a3c16fc17be25f6c.pngDoodleDash 클래스에서 Player 인스턴스를 만듭니다.

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
  DoodleDash({super.children});

  final World _world = World();
  LevelManager levelManager = LevelManager();
  GameManager gameManager = GameManager();
  int screenBufferSpace = 300;
  ObjectManager objectManager = ObjectManager();

  late Player player;                                                // Add this line
  ...
}

a3c16fc17be25f6c.png 이제 플레이어가 선택한 난이도에 따라 Player 점프 속도를 초기화 및 구성하고 FlameGamePlayer 구성요소를 추가합니다. 다음 코드로 setCharacter 메서드를 작성합니다.

lib/game/doodle_dash.dart

void setCharacter() {
  player = Player(                                           // Add lines from here...
     character: gameManager.character,
     jumpSpeed: levelManager.startingJumpSpeed,
   );
  add(player);                                                         // ... to here.
}

a3c16fc17be25f6c.pnginitializeGameStart 시작 부분에서 setCharacter 메서드를 호출합니다.

lib/game/doodle_dash.dart

void initializeGameStart() {
    setCharacter();                                                   // Add this line

    ...
}

a3c16fc17be25f6c.png또한 initializeGameStart에서 플레이어의 resetPosition을 호출하여 게임이 시작될 때마다 시작 위치로 돌아가도록 합니다.

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...

    levelManager.reset();

    player.resetPosition();                                           // Add this line

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    ...
  }

a3c16fc17be25f6c.png 앱을 실행하면 게임이 시작되고 Dash가 화면에 표시됩니다.

ed15a9c6762595c9.png

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

5. 플랫폼 추가

이 단계에서는 플랫폼(Dash가 착지하고 점프할 수 있도록)과 충돌 감지 로직을 추가하여 Dash가 점프해야 하는 시점을 결정합니다.

먼저 Platform 추상 클래스를 검토합니다.

lib/game/sprites/platform.dart

abstract class Platform<T> extends SpriteGroupComponent<T>
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  bool isMoving = false;

  Platform({
    super.position,
  }) : super(
          size: Vector2.all(100),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    await add(hitbox);
  }
}

히트박스란 무엇인가요?

Doodle Dash에서 도입된 모든 플랫폼 구성요소는 히트박스가 있는 SpriteComponentPlatform<T> 추상 클래스를 확장합니다. 히트박스를 사용하면 스프라이트 구성요소가 히트박스가 있는 다른 객체와 충돌할 때를 감지할 수 있습니다. Flame에서는 직사각형, 원형, 다각형 등 다양한 히트박스 모양을 지원합니다. 예를 들어 Doodle Dash에서는 플랫폼에 직사각형 히트박스를 사용하고 Dash에는 원형 히트박스를 사용합니다. Flame은 충돌을 계산하는 수학을 처리합니다.

Platform 클래스는 히트박스와 충돌 콜백을 모든 하위유형에 추가합니다.

표준 플랫폼 추가

Platform 클래스는 플랫폼을 게임에 추가합니다. 일반 플랫폼은 무작위로 선택된 네 가지 시각 장치(모니터, 휴대전화, 터미널, 노트북) 중 하나로 표시됩니다. 시각 장치 선택은 플랫폼의 동작에 영향을 미치지 않습니다.

NormalPlatform

a3c16fc17be25f6c.pngNormalPlatformState enum과 NormalPlatform 클래스를 추가하여 일반 정적 플랫폼을 추가합니다.

lib/game/sprites/platform.dart

enum NormalPlatformState { only }                            // Add lines from here...

class NormalPlatform extends Platform<NormalPlatformState> {
  NormalPlatform({super.position});

  final Map<String, Vector2> spriteOptions = {
    'platform_monitor': Vector2(115, 84),
    'platform_phone_center': Vector2(100, 55),
    'platform_terminal': Vector2(110, 83),
    'platform_laptop': Vector2(100, 63),
  };

  @override
  Future<void>? onLoad() async {
    var randSpriteIndex = Random().nextInt(spriteOptions.length);

    String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);

    sprites = {
      NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
    };

    current = NormalPlatformState.only;

    size = spriteOptions[randSprite]!;
    await super.onLoad();
  }
}                                                                      // ... to here.

캐릭터가 상호작용할 플랫폼을 생성합니다.

ObjectManager 클래스는 Flame의 Component 클래스를 확장하고 게임 전반에 걸쳐 Platform 객체를 생성합니다. ObjectManagerupdateonMount 메서드에서 플랫폼 생성 기능을 구현합니다.

a3c16fc17be25f6c.png새 메서드 _semiRandomPlatform을 만들어 ObjectManager 클래스에서 플랫폼을 생성합니다. 나중에 이 메서드를 업데이트하여 다양한 유형의 플랫폼을 반환하지만 지금은 NormalPlatform만 반환합니다.

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {             // Add lines from here...
    return NormalPlatform(position: position);
}                                                                      // ... to here.

a3c16fc17be25f6c.pngObjectManagerupdate 메서드를 재정의하고, _semiRandomPlatform 메서드를 사용하여 플랫폼을 생성하고 게임에 추가합니다.

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void update(double dt) {
   final topOfLowestPlatform =
       _platforms.first.position.y + _tallestPlatformHeight;

   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;

   if (topOfLowestPlatform > screenBottom) {
     var newPlatY = _generateNextY();
     var newPlatX = _generateNextX(100);
     final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
     add(nextPlat);

     _platforms.add(nextPlat);

     gameRef.gameManager.increaseScore();

     _cleanupPlatforms();
     // Losing the game: Add call to _maybeAddEnemy()
     // Powerups: Add call to _maybeAddPowerup();
   }

   super.update(dt);
 }                                                                     // ... to here.

ObjectManageronMount 메서드에서도 동일한 작업을 실행하여 게임이 처음 실행될 때 _semiRandomPlatform 메서드가 시작 플랫폼을 생성하고 게임에 이를 추가할 수 있도록 합니다.

a3c16fc17be25f6c.png다음 코드를 사용하여 onMount 메서드를 추가합니다.

lib/game/managers/object_manager.dart

 @override                                                   // Add lines from here...
 void onMount() {
   super.onMount();

   var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;

   var currentY =
       gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;

   for (var i = 0; i < 9; i++) {
     if (i != 0) {
       currentX = _generateNextX(100);
       currentY = _generateNextY();
     }
     _platforms.add(
       _semiRandomPlatform(
         Vector2(
           currentX,
           currentY,
         ),
       ),
     );

     add(_platforms[i]);
   }
 }                                                                     // ... to here.

예를 들어 다음 코드와 같이 configure 메서드를 사용하면 Doodle Dash 게임에서 플랫폼 간의 최소 및 최대 거리를 재구성할 수 있고, 난이도가 올라갈 때 특수 플랫폼을 사용 설정할 수 있습니다.

lib/game/managers/object_manager.dart

 void configure(int nextLevel, Difficulty config) {
    minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
    maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;

    for (int i = 1; i <= nextLevel; i++) {
      enableLevelSpecialty(i);
    }
  }

DoodleDash 인스턴스(initializeGameStart 메서드에 있음)는 난이도에 따라 초기화 및 구성되어 Flame 게임에 추가되는 ObjectManager를 만듭니다.

lib/game/doodle_dash.dart

  void initializeGameStart() {
    gameManager.reset();

    if (children.contains(objectManager)) objectManager.removeFromParent();

    levelManager.reset();

    player.resetPosition();

    objectManager = ObjectManager(
        minVerticalDistanceToNextPlatform: levelManager.minDistance,
        maxVerticalDistanceToNextPlatform: levelManager.maxDistance);

    add(objectManager);

    objectManager.configure(levelManager.level, levelManager.difficulty);
  }

ObjectManagercheckLevelUp 메서드에 다시 표시됩니다. 플레이어가 레벨업되면 ObjectManager는 난이도에 따라 플랫폼 생성 매개변수를 재구성합니다.

lib/game/doodle_dash.dart

  void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);
    }
  }

a3c16fc17be25f6c.png 핫 리로드 7f9a9e103c7b5e5.png하여(또는 웹에서 테스트하는 경우 다시 시작하여) 변경사항을 활성화합니다. 파일을 저장하고 IDE의 버튼을 사용하거나 명령줄에서 r을 입력하여 핫 리로드합니다. 게임을 시작하면 Dash와 플랫폼이 화면에 표시됩니다.

7c6a6c6e630c42ce.png

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

6. 핵심 게임플레이

개별 PlayerPlatform 위젯을 구현했으므로 이제 모든 항목을 가져올 수 있습니다. 이 단계에서는 핵심 기능과 충돌 감지, 카메라 움직임을 구현합니다.

중력

게임을 좀 더 실감 나게 하려면 점프할 때 아래로 끌어당기는 힘인 중력에 따라 Dash가 움직이도록 해야 합니다. 여기서 사용하는 Doodle Dash 버전에서는 중력이 일정한 양수 값으로 유지되므로 항상 Dash를 아래로 끌어당깁니다. 그러나 앞으로는 중력을 변경하여 다른 효과를 낼 수도 있습니다.

a3c16fc17be25f6c.png Player 클래스에서 값이 9인 _gravity 속성을 추가합니다.

lib/game/sprites/player.dart

class Player extends SpriteGroupComponent<PlayerState>
    with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {

  ...

  Character character;
  double jumpSpeed;
  final double _gravity = 9;                                         // Add this line

  @override
  Future<void> onLoad() async {
    ...
  }
  ...
}

a3c16fc17be25f6c.pngPlayerupdate 메서드를 수정하여 Dash의 수직 속도에 영향을 미치는 _gravity 변수를 추가합니다.

lib/game/sprites/player.dart

 void update(double dt) {
   if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;

   _velocity.x = _hAxisInput * jumpSpeed;
   final double dashHorizontalCenter = size.x / 2;

   if (position.x < dashHorizontalCenter) {
     position.x = gameRef.size.x - (dashHorizontalCenter);
   }
   if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
     position.x = dashHorizontalCenter;
   }

   _velocity.y += _gravity;                                          // Add this line

   position += _velocity * dt;
   super.update(dt);
 }

충돌 감지

Flame은 기본적으로 충돌 감지를 지원합니다. Flame 게임에서 이를 사용 설정하려면 HasCollisionDetection 믹스인을 추가합니다. DoodleDash 클래스를 살펴보면 이 믹스인이 이미 추가된 것을 알 수 있습니다.

lib/game/doodle_dash.dart

class DoodleDash extends FlameGame
    with HasKeyboardHandlerComponents, HasCollisionDetection {
    ...
}

이제 CollisionCallbacks 믹스인을 사용하여 개별 게임 구성요소에 충돌 감지를 추가합니다. 이 믹스인은 구성요소에 onCollision 콜백 액세스 권한을 제공합니다. 히트박스가 있는 두 객체가 충돌하면 onCollision 콜백이 트리거되고 충돌하는 객체에 대한 참조가 전달되므로 객체가 반응하는 방식에 관한 로직을 구현할 수 있습니다.

이전 단계를 떠올려 보면 Platform 추상 클래스에 이미 CollisionCallbacks 믹스인과 히트박스가 있습니다. Player 클래스에도 CollisionCallbacks 믹스인이 있으므로 Player 클래스에 CircleHitbox만 추가하면 됩니다. Dash의 히트박스는 사실 원형입니다. Dash가 직사각형보다는 원형에 가깝기 때문입니다.

a3c16fc17be25f6c.png Player 클래스에서 sprites.dart를 가져와 다양한 Platform 클래스에 액세스할 수 있도록 합니다.

lib/game/sprites/player.dart

import 'sprites.dart';

a3c16fc17be25f6c.png Player 클래스의 onLoad 메서드에 CircleHitbox를 추가합니다.

lib/game/sprites/player.dart

@override
Future<void> onLoad() async {
  await super.onLoad();

  await add(CircleHitbox());                                         // Add this line

  await _loadCharacterSprites();
  current = PlayerState.center;
}

Dash는 플랫폼과 부딪칠 때 점프할 수 있도록 점프 메서드가 있어야 합니다.

a3c16fc17be25f6c.png 선택적으로 specialJumpSpeed를 사용하는 jump 메서드를 추가합니다.

lib/game/sprites/player.dart

void jump({double? specialJumpSpeed}) {
  _velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}

a3c16fc17be25f6c.png다음 코드를 추가하여 PlayeronCollision 메서드를 재정의합니다.

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);
   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     }
   }
 }

이 콜백은 Dash가 아래쪽으로 떨어져 NormalPlatform의 상단과 부딪힐 때마다 Dash의 jump 메서드를 호출합니다. isMovingDown && isCollidingVertically 문은 점프를 트리거하지 않고도 Dash가 플랫폼을 통해 위로 이동하도록 합니다.

카메라 움직임

카메라는 게임에서 Dash가 위로 이동할 때 Dash를 따라가야 하지만 Dash가 아래로 떨어질 때는 움직이지 않아야 합니다.

Flame에서 '세계'가 화면보다 크면 카메라의 worldBounds를 사용하여 Flame에 표시해야 할 세계의 부분을 알려주는 경계를 추가합니다. 카메라가 위로 이동하지만 수평으로는 고정된 상태를 유지함을 보여주려면 플레이어의 위치에 따라 각 업데이트에서 상단 및 하단 세계 경계를 조정하되 왼쪽과 오른쪽 경계는 동일하게 유지합니다.

a3c16fc17be25f6c.pngDoodleDash 클래스에서 update 메서드에 다음 코드를 추가하여 게임 플레이 중에 Dash를 따라가는 카메라를 사용 설정합니다.

lib/game/doodle_dash.dart

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

    if (gameManager.isIntro) {
      overlays.add('mainMenuOverlay');
      return;
    }

    if (gameManager.isPlaying) {
      checkLevelUp();

                                                            // Add lines from here...
      final Rect worldBounds = Rect.fromLTRB(
        0,
        camera.position.y - screenBufferSpace,
        camera.gameSize.x,
        camera.position.y + _world.size.y,
      );
      camera.worldBounds = worldBounds;

      if (player.isMovingDown) {
        camera.worldBounds = worldBounds;
      }

      var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
      if (!player.isMovingDown && isInTopHalfOfScreen) {
        camera.followComponent(player);
      }                                                               // ... to here.
    }
  }

이제 Player 위치와 카메라 경계는 게임이 다시 시작될 때마다 원래 값으로 재설정해야 합니다.

a3c16fc17be25f6c.pnginitializeGameStart 메서드에 다음 코드를 추가합니다.

lib/game/doodle_dash.dart

void initializeGameStart() {
    ...
    levelManager.reset();

                                                        // Add the lines from here...
    player.reset();
    camera.worldBounds = Rect.fromLTRB(
      0,
      -_world.size.y,
      camera.gameSize.x,
      _world.size.y +
          screenBufferSpace,
    );
    camera.followComponent(player);
                                                                      // ... to here.

   player.resetPosition();
    ...
  }

레벨업할 때 점프 속도 증가

마지막 핵심 게임플레이는 난이도가 올라가고 플랫폼이 더 먼 간격으로 생성될 때마다 Dash의 점프 속도를 높여야 하는 것입니다.

a3c16fc17be25f6c.pngsetJumpSpeed 메서드 호출을 추가하고 현재 단계와 연결된 점프 속도를 제공합니다.

lib/game/doodle_dash.dart

void checkLevelUp() {
    if (levelManager.shouldLevelUp(gameManager.score.value)) {
      levelManager.increaseLevel();

      objectManager.configure(levelManager.level, levelManager.difficulty);

      player.setJumpSpeed(levelManager.jumpSpeed);                   // Add this line
    }
  }

a3c16fc17be25f6c.png 핫 리로드 7f9a9e103c7b5e5.png(또는 웹에서 다시 시작하여)하여 변경사항을 활성화합니다. 파일을 저장하고 IDE의 버튼을 사용하거나 명령줄에서 r을 입력하여 핫 리로드합니다.

2bc7c856064d74ca.gif

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

7. 플랫폼 자세히 알아보기

이제 ObjectManager에서 Dash가 점프할 수 있는 플랫폼을 생성하므로 재미있는 특수 플랫폼을 제공할 수 있습니다.

BrokenPlatformSpringBoard 클래스를 추가합니다. 이름에서 알 수 있듯이 BrokenPlatform은 한 번 점프하면 부서지고 SpringBoard는 Dash가 더 높이 더 빠르게 튀어 오르는 트램펄린을 제공합니다.

BrokenPlatform

SpringBoard

Player 클래스와 같이 이러한 각 플랫폼 클래스는 enums를 사용하여 현재 상태를 나타냅니다.

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }

플랫폼의 current 상태가 변경되면 게임 내에 표시되는 스프라이트도 변경됩니다. 각 상태에 할당되는 스프라이트와 연관시키기 위해 sprites 속성에서 State enum과 이미지 애셋 간의 매핑을 정의합니다.

a3c16fc17be25f6c.pngBrokenPlatformState enum과 BrokenPlatform 클래스를 추가합니다.

lib/game/sprites/platform.dart

enum BrokenPlatformState { cracked, broken }                // Add lines from here...

class BrokenPlatform extends Platform<BrokenPlatformState> {
  BrokenPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <BrokenPlatformState, Sprite>{
      BrokenPlatformState.cracked:
          await gameRef.loadSprite('game/platform_cracked_monitor.png'),
      BrokenPlatformState.broken:
          await gameRef.loadSprite('game/platform_monitor_broken.png'),
    };

    current = BrokenPlatformState.cracked;
    size = Vector2(115, 84);
  }

  void breakPlatform() {
    current = BrokenPlatformState.broken;
  }
}                                                                     // ... to here.

a3c16fc17be25f6c.pngSpringState enum과 SpringBoard 클래스를 추가합니다.

lib/game/sprites/platform.dart

enum SpringState { down, up }                                // Add lines from here...

class SpringBoard extends Platform<SpringState> {
  SpringBoard({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    sprites = <SpringState, Sprite>{
      SpringState.down:
          await gameRef.loadSprite('game/platform_trampoline_down.png'),
      SpringState.up:
          await gameRef.loadSprite('game/platform_trampoline_up.png'),
    };

    current = SpringState.up;

    size = Vector2(100, 45);
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isCollidingVertically) {
      current = SpringState.down;
    }
  }

  @override
  void onCollisionEnd(PositionComponent other) {
    super.onCollisionEnd(other);

    current = SpringState.up;
  }
}                                                                      // ... to here.

이제 ObjectManager에서 이러한 특수 플랫폼을 사용 설정합니다. 특수 플랫폼에 맞게 게임에 항상 표시되지 않는 것이 좋으므로 확률에 따라 조건부로 생성합니다(예: SpringBoard의 경우 15%, BrokenPlatform의 경우 10%).

a3c16fc17be25f6c.pngObjectManager_semiRandomPlatform 메서드 내에서 NormalPlatform을 반환하는 문 앞에 다음 코드를 추가하여 조건부로 특수 플랫폼을 반환합니다.

lib/game/managers/object_manager.dart

Platform _semiRandomPlatform(Vector2 position) {
   if (specialPlatforms['spring'] == true &&                 // Add lines from here...
       probGen.generateWithProbability(15)) {
     return SpringBoard(position: position);
   }

   if (specialPlatforms['broken'] == true &&
       probGen.generateWithProbability(10)) {
     return BrokenPlatform(position: position);
   }                                                                   // ... to here.

   return NormalPlatform(position: position);
}

게임을 하는 즐거움 중 하나는 레벨업할 때 새로운 도전과제와 기능을 잠금 해제하는 것입니다.

1단계 시작부터 스프링보드가 채워지길 바라지만 Dash가 2단계에 도달하면 BrokenPlatform이 잠금 해제되어 게임이 조금 더 어려워집니다.

a3c16fc17be25f6c.pngObjectManager 클래스에서 1단계에 SpringBoard 플랫폼을 사용 설정하고 2단계에 BrokenPlatform을 사용 설정하는 switch 문을 추가하여 enableLevelSpecialty 메서드(현재는 스텁임)를 수정합니다.

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {                                           // Add lines from here...
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
  }                                                                    // ... to here.
}

a3c16fc17be25f6c.png플랫폼에 수평으로 이동하는 기능을 제공합니다. Platform 추상 클래스에서**** 다음 _move 메서드를 추가합니다.

lib/game/sprites/platform.dart

void _move(double dt) {
    if (!isMoving) return;

    final double gameWidth = gameRef.size.x;

    if (position.x <= 0) {
      direction = 1;
    } else if (position.x >= gameWidth - size.x) {
      direction = -1;
    }

    _velocity.x = direction * speed;

    position += _velocity * dt;
}

플랫폼이 이동하는 경우 게임 화면의 가장자리에 도달하면 반대 방향으로 이동 방향을 변경합니다. Dash와 마찬가지로 플랫폼의 위치는 _direction에 플랫폼 speed를 곱하여 속도를 구함으로써 결정됩니다. 그런 다음, 속도에 time-elapsed를 곱하고 결과 거리를 플랫폼의 현재 position에 추가합니다.

a3c16fc17be25f6c.png_move 메서드를 호출하도록 Platform 클래스의 update 메서드를 재정합니다.

lib/game/sprites/platform.dart

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

a3c16fc17be25f6c.png움직이는 Platform을 트리거하려면 onLoad 메서드에서 임의로 isMoving 불리언 값을 20% 확률로 true로 설정합니다.

lib/game/sprites/platform.dart

@override
Future<void>? onLoad() async {
  await super.onLoad();

  await add(hitbox);

  final int rand = Random().nextInt(100);                            // Add this line
  if (rand > 80) isMoving = true;                                    // Add this line
}

a3c16fc17be25f6c.png마지막으로 Player에서 Springboard 또는 BrokenPlatform과의 충돌을 인식하도록 Player 클래스의 onCollision 메서드를 수정합니다. SpringBoard는 2배 속도 승수로 jump를 호출하고 BrokenPlatform은 상태가 .broken이 아닌(이미 점프함) .cracked인 경우에만 jump를 호출합니다.

lib/game/sprites/player.dart

@override
 void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
   super.onCollision(intersectionPoints, other);

   bool isCollidingVertically =
       (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

   if (isMovingDown && isCollidingVertically) {
     current = PlayerState.center;
     if (other is NormalPlatform) {
       jump();
       return;
     } else if (other is SpringBoard) {                      // Add lines from here...
       jump(specialJumpSpeed: jumpSpeed * 2);
       return;
     } else if (other is BrokenPlatform &&
         other.current == BrokenPlatformState.cracked) {
       jump();
       other.breakPlatform();
       return;
     }                                                                 // ... to here.
   }
 }

a3c16fc17be25f6c.png 앱을 다시 시작합니다. 게임을 시작하면 움직이는 플랫폼 SpringBoardBrokenPlatform이 표시됩니다.

d4949925e897f665.gif

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

8. 게임에서 지기

이 단계에서는 Doodle Dash 게임에 지는 조건을 추가합니다. 플레이어가 질 수 있는 방법은 두 가지입니다.

  1. Dash가 플랫폼을 놓쳐 화면 하단 아래로 떨어집니다.
  2. Dash가 Enemy 플랫폼과 부딪힙니다.

'게임 종료' 조건을 구현하려면 DoodleDash 게임 상태를 gameOver로 설정하는 로직을 추가해야 합니다.

a3c16fc17be25f6c.pngDoodleDash 클래스****에서 게임이 종료되어야 할 때마다 호출되는 onLose 메서드를 추가합니다. 이 메서드는 게임 상태를 설정하고 화면에서 플레이어를 삭제하며 **Game Over** 메뉴/오버레이를 활성화합니다.

lib/game/sprites/doodle_dash.dart

 void onLose() {                                             // Add lines from here...
    gameManager.state = GameState.gameOver;
    player.removeFromParent();
    overlays.add('gameOverOverlay');
  }                                                                    // ... to here.

Game Over 메뉴:

6a79b43f4a1f780d.png

a3c16fc17be25f6c.pngDoodleDashupdate 메서드 상단에 다음 코드를 추가하여 게임 상태가 GameOver일 때 게임의 업데이트를 중지합니다.

lib/game/sprites/doodle_dash.dart

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

   if (gameManager.isGameOver) {                             // Add lines from here...
     return;
   }                                                                   // ... to here.
   ...
}

a3c16fc17be25f6c.png또한 update 메서드에서 플레이어가 화면 하단 아래로 떨어졌을 때 onLose를 호출합니다.

lib/game/sprites/doodle_dash.dart

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

   if (gameManager.isPlaying) {
     checkLevelUp();

     final Rect worldBounds = Rect.fromLTRB(
       0,
       camera.position.y - screenBufferSpace,
       camera.gameSize.x,
       camera.position.y + _world.size.y,
     );
     camera.worldBounds = worldBounds;
     if (player.isMovingDown) {
       camera.worldBounds = worldBounds;
     }

     var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
     if (!player.isMovingDown && isInTopHalfOfScreen) {
       camera.followComponent(player);
     }

                                                             // Add lines from here...
     if (player.position.y >
         camera.position.y +
             _world.size.y +
             player.size.y +
             screenBufferSpace) {
       onLose();
     }                                                                 // ... to here.
   }
 }

적은 다양한 모양과 크기로 나타날 수 있습니다. Doodle Dash에서 적은 쓰레기통이나 오류 폴더 아이콘으로 표시됩니다. 플레이어는 이러한 적과 충돌하지 않아야 합니다. 충돌하면 게임이 바로 종료됩니다.

Enemy

a3c16fc17be25f6c.pngEnemyPlatformState enum과 EnemyPlatform 클래스를 추가하여 적 플랫폼 유형을 만듭니다.

lib/game/sprites/platform.dart

enum EnemyPlatformState { only }                             // Add lines from here...

class EnemyPlatform extends Platform<EnemyPlatformState> {
  EnemyPlatform({super.position});

  @override
  Future<void>? onLoad() async {
    var randBool = Random().nextBool();
    var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';

    sprites = <EnemyPlatformState, Sprite>{
      EnemyPlatformState.only:
          await gameRef.loadSprite('game/$enemySprite.png'),
    };

    current = EnemyPlatformState.only;

    return super.onLoad();
  }
}                                                                      // ... to here.

EnemyPlatform 클래스는 Platform 상위유형을 확장합니다. ObjectManager는 다른 모든 플랫폼과 마찬가지로 적 플랫폼을 생성하고 관리합니다.

a3c16fc17be25f6c.pngObjectManager에서 다음 코드를 추가하여 적 플랫폼을 생성하고 관리합니다.

lib/game/managers/object_manager.dart

final List<EnemyPlatform> _enemies = [];                    // Add lines from here...
void _maybeAddEnemy() {
  if (specialPlatforms['enemy'] != true) {
    return;
  }
  if (probGen.generateWithProbability(20)) {
    var enemy = EnemyPlatform(
      position: Vector2(_generateNextX(100), _generateNextY()),
    );
    add(enemy);
    _enemies.add(enemy);
    _cleanupEnemies();
  }
}

void _cleanupEnemies() {
  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;

  while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
    remove(_enemies.first);
    _enemies.removeAt(0);
  }
}                                                                      // ... to here.

ObjectManager는 적 객체 목록 _enemies를 유지합니다. _maybeAddEnemy는 20% 확률로 적을 생성하고 적 목록에 해당 객체를 추가합니다. _cleanupEnemies() 메서드는 더 이상 표시되지 않는 오래된 EnemyPlatform 객체를 삭제합니다.

a3c16fc17be25f6c.pngObjectManagerupdate 메서드에서 _maybeAddEnemy()를 호출하여 적 플랫폼을 생성합니다.

lib/game/managers/object_manager.dart

@override
void update(double dt) {
  final topOfLowestPlatform =
      _platforms.first.position.y + _tallestPlatformHeight;

  final screenBottom = gameRef.player.position.y +
      (gameRef.size.x / 2) +
      gameRef.screenBufferSpace;
  if (topOfLowestPlatform > screenBottom) {
    var newPlatY = _generateNextY();
    var newPlatX = _generateNextX(100);
    final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
    add(nextPlat);

    _platforms.add(nextPlat);
    gameRef.gameManager.increaseScore();

    _cleanupPlatforms();
    _maybeAddEnemy();                                                 // Add this line
  }

  super.update(dt);
}

a3c16fc17be25f6c.pngPlayeronCollision 메서드에 추가하여 EnemyPlatform과 충돌하는지 확인합니다. 충돌하면 onLose() 메서드를 호출합니다.

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform) {                           // Add lines from here...
      gameRef.onLose();
      return;
    }                                                                 // ... to here.

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }
  }

a3c16fc17be25f6c.png이제 ObjectManagerenableLevelSpecialty 메서드를 수정하여 switch 문에 5단계를 추가합니다.

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
  switch (level) {
    case 1:
      enableSpecialty('spring');
      break;
    case 2:
      enableSpecialty('broken');
      break;
    case 5:                                                  // Add lines from here...
      enableSpecialty('enemy');
      break;                                                           // ... to here.
  }
}

a3c16fc17be25f6c.png 이제 게임을 더 어렵게 만들었으므로 핫 리로드 7f9a9e103c7b5e5.png하여 변경사항을 활성화합니다. 파일을 저장하고 IDE의 버튼을 사용하거나 명령줄에서 r을 입력하여 핫 리로드합니다.

깨진 폴더 적을 조심하세요. 배경과 잘 조화를 이뤄 피하기 어렵습니다.

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

9. 파워업

이 단계에서는 게임 전반에 걸쳐 Dash를 파워업하는 향상된 게임 기능을 추가합니다. Doodle Dash에는 파워업 옵션이 두 가지 있습니다. 하나는 누글러 모자이고 다른 하나는 로켓입니다. 이러한 파워업은 또 다른 유형의 특수 플랫폼으로 생각하면 됩니다. Dash가 게임에서 점프할 때 누글러 모자 또는 로켓 파워업에 충돌하거나 이를 소유하면 속도가 가속됩니다.

NooglerHat

Rocket

누글러 모자는 플레이어가 점수 40점 이상을 달성하면 3단계에서 생성됩니다. Dash가 모자와 충돌하면 누글러 모자를 쓰고 보통 속도의 2.5배인 가속 부스트를 받게 됩니다. 이 효과는 5초간 지속됩니다.

로켓은 플레이어가 점수 80점 이상을 달성하면 4단계에서 생성됩니다. Dash가 로켓과 충돌하면 Dash의 스프라이트가 로켓으로 바뀌고 플랫폼에 착지할 때까지 보통 속도의 3.5배인 가속 부스트를 받습니다. 또한 로켓 파워업을 사용할 때는 적에게도 천하무적입니다.

누글러 모자와 로켓 스프라이트는 PowerUp 추상 클래스를 확장합니다. Platform 추상 클래스와 마찬가지로 PowerUp 추상 클래스(아래 참고)에도 크기 조절과 히트박스가 포함됩니다.

lib/game/sprites/powerup.dart

abstract class PowerUp extends SpriteComponent
    with HasGameRef<DoodleDash>, CollisionCallbacks {
  final hitbox = RectangleHitbox();
  double get jumpSpeedMultiplier;

  PowerUp({
    super.position,
  }) : super(
          size: Vector2.all(50),
          priority: 2,
        );

  @override
  Future<void>? onLoad() async {
    await super.onLoad();

    await add(hitbox);
  }
}

a3c16fc17be25f6c.png PowerUp 추상 클래스를 확장하는 Rocket 클래스를 만듭니다. Dash가 로켓과 충돌하면 보통 속도의 3.5배 부스트를 받습니다.

lib/game/sprites/powerup.dart

class Rocket extends PowerUp {                               // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 3.5;

  Rocket({
    super.position,
  });

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/rocket_1.png');
    size = Vector2(50, 70);
  }
}                                                                      // ... to here.

a3c16fc17be25f6c.png PowerUp 추상 클래스를 확장하는 NooglerHat 클래스를 만듭니다. Dash가 NooglerHat과 충돌하면 보통 속도의 2.5배 가속 부스트를 받습니다. 증가한 가속은 5초간 지속됩니다.

lib/game/sprites/powerup.dart

class NooglerHat extends PowerUp {                          // Add lines from here...
  @override
  double get jumpSpeedMultiplier => 2.5;

  NooglerHat({
    super.position,
  });

  final int activeLengthInMS = 5000;

  @override
  Future<void>? onLoad() async {
    await super.onLoad();
    sprite = await gameRef.loadSprite('game/noogler_hat.png');
    size = Vector2(75, 50);
  }
}                                                                      // ... to here.

NooglerHatRocket 파워업을 구현했으므로 이제 ObjectManager를 업데이트하여 게임에서 이를 생성합니다.

a3c16fc17be25f6c.png ObjectManger 클래스를 수정하여 생성된 파워업을 추적하는 목록을 추가하고 새 파워업 플랫폼을 생성 및 삭제하는 새로운 두 메서드(_maybePowerup_cleanupPowerups)를 추가합니다.

lib/game/managers/object_manager.dart

final List<PowerUp> _powerups = [];                          // Add lines from here...

 void _maybeAddPowerup() {
   if (specialPlatforms['noogler'] == true &&
       probGen.generateWithProbability(20)) {
     var nooglerHat = NooglerHat(
       position: Vector2(_generateNextX(75), _generateNextY()),
     );
     add(nooglerHat);
     _powerups.add(nooglerHat);
   } else if (specialPlatforms['rocket'] == true &&
       probGen.generateWithProbability(15)) {
     var rocket = Rocket(
       position: Vector2(_generateNextX(50), _generateNextY()),
     );
     add(rocket);
     _powerups.add(rocket);
   }

   _cleanupPowerups();
 }

 void _cleanupPowerups() {
   final screenBottom = gameRef.player.position.y +
       (gameRef.size.x / 2) +
       gameRef.screenBufferSpace;
   while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
     if (_powerups.first.parent != null) {
       remove(_powerups.first);
     }
     _powerups.removeAt(0);
   }
 }                                                                     // ... to here.

_maybeAddPowerup 메서드는 20% 확률로 누글러 모자를 생성하고 15% 확률로 로켓을 생성합니다. _cleanupPowerups 메서드는 화면 하단 경계 아래에 있는 파워업을 삭제할 때 호출됩니다.

a3c16fc17be25f6c.png ObjectManager update 메서드를 수정하여 게임 루프의 각 틱에서 _maybePowerup을 호출합니다.

lib/game/managers/object_manager.dart

@override
  void update(double dt) {
    final topOfLowestPlatform =
        _platforms.first.position.y + _tallestPlatformHeight;

    final screenBottom = gameRef.player.position.y +
        (gameRef.size.x / 2) +
        gameRef.screenBufferSpace;

    if (topOfLowestPlatform > screenBottom) {
      var newPlatY = _generateNextY();
      var newPlatX = _generateNextX(100);
      final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
      add(nextPlat);

      _platforms.add(nextPlat);

      gameRef.gameManager.increaseScore();

      _cleanupPlatforms();
      _maybeAddEnemy();
      _maybeAddPowerup();                                            // Add this line
    }

    super.update(dt);
  }

a3c16fc17be25f6c.pngenableLevelSpecialty 메서드를 수정하여 switch 문에 새로운 사례 두 가지를 추가합니다. 하나는 3단계에서 NooglerHat을 사용 설정하는 사례이고 다른 하나는 4단계에서 Rocket을 사용 설정하는 사례입니다.

lib/game/managers/object_manager.dart

void enableLevelSpecialty(int level) {
    switch (level) {
      case 1:
        enableSpecialty('spring');
        break;
      case 2:
        enableSpecialty('broken');
        break;
      case 3:                                               // Add lines from here...
        enableSpecialty('noogler');
        break;
      case 4:
        enableSpecialty('rocket');
        break;                                                        // ... to here.
      case 5:
        enableSpecialty('enemy');
        break;
    }
  }

a3c16fc17be25f6c.png Player 클래스에 다음 불리언 getter를 추가합니다. Dash가 활성 파워업을 보유한 경우 다양한 상태로 표현될 수 있습니다. 이러한 getter를 통해 어떤 파워업이 활성화되어 있는지 쉽게 확인할 수 있습니다.

lib/game/sprites/player.dart

 bool get hasPowerup =>                                      // Add lines from here...
     current == PlayerState.rocket ||
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;

 bool get isInvincible => current == PlayerState.rocket;

 bool get isWearingHat =>
     current == PlayerState.nooglerLeft ||
     current == PlayerState.nooglerRight ||
     current == PlayerState.nooglerCenter;                             // ... to here.

a3c16fc17be25f6c.pngPlayeronCollision 메서드를 수정하여 NooglerHat 또는 Rocket과의 충돌에 반응합니다. 이 코드는 또한 Dash가 파워업을 이미 보유하고 있지 않을 때만 새 파워업을 활성화하도록 합니다.

lib/game/sprites/player.dart

@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollision(intersectionPoints, other);
    if (other is EnemyPlatform && !isInvincible) {
      gameRef.onLose();
      return;
    }

    bool isCollidingVertically =
        (intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;

    if (isMovingDown && isCollidingVertically) {
      current = PlayerState.center;
      if (other is NormalPlatform) {
        jump();
        return;
      } else if (other is SpringBoard) {
        jump(specialJumpSpeed: jumpSpeed * 2);
        return;
      } else if (other is BrokenPlatform &&
          other.current == BrokenPlatformState.cracked) {
        jump();
        other.breakPlatform();
        return;
      }
    }

    if (!hasPowerup && other is Rocket) {                    // Add lines from here...
      current = PlayerState.rocket;
      other.removeFromParent();
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    } else if (!hasPowerup && other is NooglerHat) {
      if (current == PlayerState.center) current = PlayerState.nooglerCenter;
      if (current == PlayerState.left) current = PlayerState.nooglerLeft;
      if (current == PlayerState.right) current = PlayerState.nooglerRight;
      other.removeFromParent();
      _removePowerupAfterTime(other.activeLengthInMS);
      jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
      return;
    }                                                                  // ... to here.
  }

Dash가 로켓과 충돌하면 PlayerStateRocket으로 변경되고 Dash는 3.5배 jumpSpeedMultiplier로 점프할 수 있습니다.

Dash가 누글러 모자와 충돌하면 현재 PlayerState 방향(.center, .left, .right)에 따라 PlayerState가 상응하는 누글러 PlayerState로 변경되며 Dash는 누글러 모자를 쓰고 증가된 2.5배 jumpSpeedMultiplier가 제공됩니다. _removePowerupAfterTime 메서드는 5초가 지나면 파워업을 삭제하고 PlayerState를 파워업 상태에서 다시 center로 변경합니다.

other.removeFromParent를 호출하면 화면에서 누글러 모자 또는 로켓 스프라이트 플랫폼이 삭제되어 Dash가 파워업을 획득했음을 반영합니다.

ede04fdfe074f471.gif

a3c16fc17be25f6c.pngNooglerHat 스프라이트를 고려하여 Player 클래스의 moveLeftmoveRight 메서드를 수정합니다. Rocket 파워업은 고려하지 않아도 됩니다. 해당 스프라이트는 이동 방향과 상관없이 동일한 방향을 향하기 때문입니다.

lib/game/sprites/player.dart

 void moveLeft() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerLeft;
   } else if (!hasPowerup) {                                           // ... to here.
     current = PlayerState.left;
   }                                                                  // Add this line
   _hAxisInput += movingLeftInput;
 }

 void moveRight() {
   _hAxisInput = 0;
   if (isWearingHat) {                                       // Add lines from here...
     current = PlayerState.nooglerRight;
   } else if (!hasPowerup) {                                            //... to here.
     current = PlayerState.right;
   }                                                                  // Add this line
   _hAxisInput += movingRightInput;
 }

Dash는 Rocket 파워업이 있을 때 적에게 천하무적이므로 이때 게임을 종료하지 않도록 하세요.

a3c16fc17be25f6c.pngonCollision 콜백을 수정하여 EnemyPlatform과 충돌할 때 게임 종료를 트리거하기 전에 Dash가 isInvincible인지 확인합니다.

lib/game/sprites/player.dart

   if (other is EnemyPlatform && !isInvincible) {                 // Modify this line
     gameRef.onLose();
     return;
   }

a3c16fc17be25f6c.png 앱을 다시 시작하고 게임을 플레이하여 파워업을 실제로 확인합니다.

e1fece51429dae55.gif

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

10. 오버레이

Flame 게임은 위젯에 래핑할 수 있으므로 Flutter 앱의 다른 위젯과 함께 통합하기 쉽습니다. Flutter 위젯을 Flame 게임 위에 오버레이로 표시할 수도 있습니다. 이는 메뉴, 일시중지 화면, 버튼, 슬라이더 등 게임 루프에 의존하지 않는 비 게임플레이 구성요소에 편리합니다.

Doodle Dash의 모든 메뉴와 함께 게임 내에서 볼 수 있는 점수 표시는 Flame 구성요소가 아닌 일반 Flutter 위젯입니다. 모든 위젯 코드는 lib/game/widgets에 있습니다. 예를 들어 Game Over 메뉴는 TextElevatedButton과 같은 다른 위젯이 포함된 열일 뿐입니다(다음 코드 참고).

lib/game/widgets/game_over_overlay.dart

class GameOverOverlay extends StatelessWidget {
 const GameOverOverlay(this.game, {super.key});

 final Game game;

 @override
 Widget build(BuildContext context) {
   return Material(
     color: Theme.of(context).colorScheme.background,
     child: Center(
       child: Padding(
         padding: const EdgeInsets.all(48.0),
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,
           crossAxisAlignment: CrossAxisAlignment.center,
           children: [
             Text(
               'Game Over',
               style: Theme.of(context).textTheme.displayMedium!.copyWith(),
             ),
             const WhiteSpace(height: 50),
             ScoreDisplay(
               game: game,
               isLight: true,
             ),
             const WhiteSpace(
               height: 50,
             ),
             ElevatedButton(
               onPressed: () {
                 (game as DoodleDash).resetGame();
               },
               style: ButtonStyle(
                 minimumSize: MaterialStateProperty.all(
                   const Size(200, 75),
                 ),
                 textStyle: MaterialStateProperty.all(
                     Theme.of(context).textTheme.titleLarge),
               ),
               child: const Text('Play Again'),
             ),
           ],
         ),
       ),
     ),
   );
 }
}

Flame 게임에서 위젯을 오버레이로 사용하려면 오버레이를 String으로 나타내는 key와 위젯을 반환하는 위젯 함수의 value를 사용하여 GameWidget에서 overlayBuilderMap 속성을 정의합니다(다음 코드 참고).

lib/main.dart

GameWidget(
  game: game,
  overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
    'gameOverlay': (context, game) => GameOverlay(game),
    'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
    'gameOverOverlay': (context, game) => GameOverOverlay(game),
  },
)

추가하면 오버레이는 게임의 어디에서나 사용할 수 있습니다. overlays.add를 사용하여 오버레이를 표시하고 overlays.remove를 사용하여 오버레이를 숨깁니다(다음 코드 참고).

lib/game/doodle_dash.dart

void resetGame() {
   startGame();
   overlays.remove('gameOverOverlay');
 }

 void onLose() {
   gameManager.state = GameState.gameOver;
   player.removeFromParent();
   overlays.add('gameOverOverlay');
 }

11. 모바일 지원

Doodle Dash는 Flutter와 Flame에 기반하므로 Flutter의 지원되는 플랫폼에서 실행됩니다. 그러나 지금까지 Doodle Dash는 키보드 기반 입력만 지원했습니다. 휴대전화와 같이 키보드가 없는 기기의 경우 화면에 표시되는 터치 컨트롤 버튼을 오버레이에 쉽게 추가할 수 있습니다.

a3c16fc17be25f6c.png 게임이 모바일 플랫폼에서 실행되는 시기를 결정하는 불리언 상태 변수를 GameOverlay에 추가합니다.

lib/game/widgets/game_overlay.dart

class GameOverlayState extends State<GameOverlay> {
 bool isPaused = false;

                                                                      // Add this line
 final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);

 @override
 Widget build(BuildContext context) {
   ...
 }
}

이제 게임이 모바일에서 실행될 때 왼쪽 및 오른쪽 방향 버튼을 오버레이에 표시합니다. 4단계의 '키 이벤트' 로직과 마찬가지로 왼쪽 버튼을 탭하면 Dash가 왼쪽으로 이동합니다. 오른쪽 버튼을 탭하면 오른쪽으로 이동합니다.

a3c16fc17be25f6c.png GameOverlaybuild 메서드에서 4단계에서 설명한 동일한 동작을 따르는 isMobile 섹션을 추가합니다. 왼쪽 버튼을 탭하면 moveLeft가 호출되고 오른쪽 버튼을 탭하면 moveRight가 호출됩니다. 버튼을 놓으면 resetDirection이 호출되고 Dash가 수평으로 이동하지 않습니다.

lib/game/widgets/game_overlay.dart

@override
 Widget build(BuildContext context) {
   return Material(
     color: Colors.transparent,
     child: Stack(
       children: [
         Positioned(... child: ScoreDisplay(...)),
         Positioned(... child: ElevatedButton(...)),
         if (isMobile)                                       // Add lines from here...
           Positioned(
             bottom: MediaQuery.of(context).size.height / 4,
             child: SizedBox(
               width: MediaQuery.of(context).size.width,
               child: Row(
                 mainAxisAlignment: MainAxisAlignment.spaceBetween,
                 children: [
                   Padding(
                     padding: const EdgeInsets.only(left: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveLeft();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_left, size: 64),
                       ),
                     ),
                   ),
                   Padding(
                     padding: const EdgeInsets.only(right: 24),
                     child: GestureDetector(
                       onTapDown: (details) {
                         (widget.game as DoodleDash).player.moveRight();
                       },
                       onTapUp: (details) {
                         (widget.game as DoodleDash).player.resetDirection();
                       },
                       child: Material(
                         color: Colors.transparent,
                         elevation: 3.0,
                         shadowColor: Theme.of(context).colorScheme.background,
                         child: const Icon(Icons.arrow_right, size: 64),
                       ),
                     ),
                   ),
                 ],
               ),
             ),
           ),                                                          // ... to here.
         if (isPaused)
           ...
       ],
     ),
   );
 }

이제 완료됐습니다. 이제 Doodle Dash 앱이 실행되는 플랫폼의 종류를 자동 감지하여 적절하게 입력을 전환합니다.

a3c16fc17be25f6c.png iOS 또는 Android에서 앱을 실행하여 실제로 작동하는 방향 버튼을 확인합니다.

7b0cac5fb69bc89.gif

문제가 있나요?

앱이 올바르게 실행되지 않는다면 오타가 있는지 확인합니다. 필요한 경우 다음 링크의 코드를 사용하면 제대로 작동합니다.

12. 다음 단계

축하합니다.

이 Codelab을 완료하고 Flame 게임 엔진을 사용하여 Flutter에서 게임을 빌드하는 방법을 알아봤습니다.

학습한 내용

  • Flame 패키지를 사용하여 다음을 비롯한 플랫폼 게임을 만드는 방법
  • 캐릭터 추가
  • 다양한 플랫폼 유형 추가
  • 충돌 감지 구현
  • 중력 구성요소 추가
  • 카메라 움직임 정의
  • 적 만들기
  • 파워업 만들기
  • 게임이 실행되는 플랫폼을 감지하는 방법
  • 이 정보를 사용하여 키보드와 터치 입력 컨트롤 간에 전환하는 방법

리소스

Flutter에서 게임 만들기에 관해 더 많이 배웠길 바랍니다.

다음 리소스도 유용하며 영감을 줄 수 있습니다.