Flutter를 사용한 Flame 소개

1. 소개

Flame은 Flutter 기반 2D 게임 엔진입니다. 이 Codelab에서는 70년대 비디오 게임의 고전 중 하나인 스티브 워즈니악의 브레이크아웃에서 영감을 받은 게임을 빌드합니다. Flame의 구성요소를 사용하여 배트, 공, 벽돌을 그립니다. Flame의 Effects를 사용하여 박쥐의 움직임에 애니메이션을 적용하고 Flame을 Flutter의 상태 관리 시스템과 통합하는 방법을 알아봅니다.

완료되면 게임이 이 애니메이션 gif와 같아 조금 느려집니다.

플레이 중인 경기의 화면 녹화 경기 속도가 상당히 빨라졌습니다.

학습할 내용

  • GameWidget부터 시작해 Flame의 기본사항 작동 방식
  • 게임 루프 사용 방법
  • Flame의 Component 작동 방식 Flutter의 Widget와 유사합니다.
  • 충돌 처리 방법
  • Effect를 사용하여 Component에 애니메이션을 적용하는 방법
  • Flame 게임 위에 Flutter Widget를 오버레이하는 방법
  • Flutter의 상태 관리와 Flame을 통합하는 방법

빌드할 항목

이 Codelab에서는 Flutter와 Flame을 사용하여 2D 게임을 빌드합니다. 완료되면 게임이 다음 요구사항을 충족해야 합니다.

  • Flutter가 지원하는 6가지 플랫폼(Android, iOS, Linux, macOS, Windows, 웹)에서 모두 작동
  • Flame의 게임 루프를 사용하여 최소 60fps를 유지합니다.
  • google_fonts 패키지 및 flutter_animate와 같은 Flutter 기능을 사용하여 80년대 아케이드 게임의 분위기를 재현합니다.

2. Flutter 환경 설정

편집자

이 Codelab을 간소화하기 위해 여기서는 Visual Studio Code (VS Code)가 개발 환경이라고 가정합니다. VS Code는 무료이며 모든 주요 플랫폼에서 작동합니다. 이 Codelab에서는 안내가 VS Code별 단축키로 기본 설정되므로 VS Code를 사용합니다. '이 버튼을 클릭하세요'와 같이 작업이 더 간단해집니다. 또는 "X를 실행하려면 이 키를 누르세요" '편집기에서 적절한 작업을 수행하여 X를 실행'하는 것이 아니라

Android 스튜디오, 기타 IntelliJ IDE, Emacs, Vim, Notepad++ 등 원하는 편집기를 사용할 수 있습니다. 모두 Flutter와 호환됩니다.

Flutter 코드가 포함된 VS Code의 스크린샷

개발 타겟 선택

Flutter는 여러 플랫폼을 위한 앱을 생성합니다. 앱이 다음 운영체제 어디서든 실행될 수 있습니다.

  • iOS
  • Android
  • Windows
  • macOS
  • Linux

하나의 운영체제를 개발 타겟으로 선택하는 것이 일반적입니다. 개발 중에 앱이 실행되는 운영체제입니다.

노트북과 케이블로 노트북에 연결된 휴대전화를 보여주는 그림 노트북에는

예를 들어 Windows 노트북을 사용하여 Flutter 앱을 개발한다고 가정해 보겠습니다. 그런 다음 개발 타겟으로 Android를 선택합니다. 앱을 미리 보려면 USB 케이블을 사용하여 Android 기기를 Windows 노트북에 연결하면 개발 중인 앱이 연결된 Android 기기나 Android Emulator에서 실행됩니다. Windows를 개발 타겟으로 선택하면 개발 중인 앱을 편집기와 함께 Windows 앱으로 실행합니다.

개발 타겟으로 웹을 선택하고 싶을 수 있습니다. 개발 중에는 단점이 있습니다. Flutter의 스테이트풀(Stateful) 핫 리로드 기능이 손실됩니다. Flutter는 현재 웹 애플리케이션을 핫 리로드할 수 없습니다.

계속하기 전에 선택하세요. 나중에 언제든지 다른 운영체제에서 앱을 실행할 수 있습니다. 개발 타겟을 선택하면 다음 단계가 더 원활하게 진행됩니다.

Flutter 설치

Flutter SDK 설치에 관한 최신 안내는 docs.flutter.dev에서 확인할 수 있습니다.

Flutter 웹사이트의 안내에서는 SDK 및 개발 타겟 관련 도구와 편집기 플러그인의 설치 방법을 다룹니다. 이 Codelab에서는 다음 소프트웨어를 설치합니다.

  1. Flutter SDK
  2. Flutter 플러그인이 있는 Visual Studio Code
  3. 선택한 개발 타겟의 컴파일러 소프트웨어입니다. Windows를 타겟팅하려면 Visual Studio가 필요하며 macOS 또는 iOS를 타겟팅하려면 Xcode가 필요합니다.

다음 섹션에서는 첫 번째 Flutter 프로젝트를 만들어 봅니다.

문제를 해결해야 하는 경우 문제 해결에 도움이 되는 다음 질문과 답변 (StackOverflow에서 제공)이 도움이 될 수 있습니다.

자주 묻는 질문(FAQ)

3. 프로젝트 만들기

첫 번째 Flutter 프로젝트 만들기

이때 VS Code를 열고 선택한 디렉터리에 Flutter 앱 템플릿을 만들어야 합니다.

  1. Visual Studio Code를 실행합니다.
  2. 명령어 팔레트 (F1 또는 Ctrl+Shift+P 또는 Shift+Cmd+P)를 열고 'flutter new'를 입력합니다. 메뉴가 표시되면 Flutter: New Project 명령어를 선택합니다.

다음이 포함된 VS Code의 스크린샷

  1. Empty Application을 선택합니다. 프로젝트를 만들 디렉터리를 선택합니다. 승격된 권한이 필요하지 않거나 경로에 공백이 있는 디렉터리여야 합니다. 예를 들어 홈 디렉터리 또는 C:\src\ 등이 있습니다.

새 애플리케이션 흐름의 일부로 선택된 빈 애플리케이션이 있는 VS Code 스크린샷

  1. 프로젝트 이름을 brick_breaker로 지정합니다. 이 Codelab의 나머지 부분에서는 앱의 이름을 brick_breaker로 지정했다고 가정합니다.

다음이 포함된 VS Code 스크린샷

이제 Flutter에서 프로젝트 폴더를 생성하고 VS Code에서 이 폴더를 엽니다. 이제 앱의 기본 스캐폴드로 두 파일의 콘텐츠를 덮어씁니다.

초기 앱 복사 및 붙여넣기

이렇게 하면 이 Codelab에서 제공한 예시 코드가 앱에 추가됩니다.

  1. VS Code의 왼쪽 창에서 Explorer를 클릭하고 pubspec.yaml 파일을 엽니다.

pubspec.yaml 파일의 위치를 강조 표시하는 화살표가 있는 VS Code의 부분 스크린샷

  1. 이 파일의 콘텐츠를 다음으로 바꿉니다.

pubspec.yaml

name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

pubspec.yaml 파일은 앱에 관한 기본 정보(예: 현재 버전, 종속 항목, 함께 제공될 애셋)를 지정합니다.

  1. lib/ 디렉터리에서 main.dart 파일을 엽니다.

main.dart 파일의 위치를 보여주는 화살표가 있는 VS Code의 부분 스크린샷

  1. 이 파일의 콘텐츠를 다음으로 바꿉니다.

lib/main.dart

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

void main() {
  final game = FlameGame();
  runApp(GameWidget(game: game));
}
  1. 이 코드를 실행하여 모든 것이 제대로 작동하는지 확인합니다. 그러면 빈 검은색 배경만 있는 새 창이 표시됩니다. 세계 최악의 비디오 게임이 현재 60fps로 렌더링되고 있습니다.

완전히 검은색인 brick_breaker 애플리케이션 창을 보여주는 스크린샷

4. 게임 만들기

게임 속도 높이기

2차원 (2D)으로 플레이하는 게임에는 플레이 영역이 필요합니다. 특정 크기로 구성된 영역을 구성한 다음 이 크기를 사용하여 게임의 다른 부분의 크기를 지정합니다.

플레이 영역에 좌표를 배치하는 방법에는 여러 가지가 있습니다. 한 가지 규칙에 따라 원점 (0,0)을 화면 중앙에 두고 화면 중앙으로부터 방향을 측정할 수 있습니다. 양수 값은 항목을 x축을 따라 오른쪽으로, y축을 따라 위로 이동합니다. 이 표준은 요즘 대부분의 최신 게임, 특히 3차원을 사용하는 게임에 적용됩니다.

최초의 브레이크아웃 게임이 만들어졌을 당시의 규칙은 왼쪽 상단 구석에 기반을 두는 것이었습니다. 양의 x 방향은 동일하게 유지되지만 y의 방향은 뒤집혔습니다. x의 양의 x 방향은 올바르고 y는 아래쪽입니다. 시대에 충실하기 위해 이 게임에서는 출발점을 왼쪽 상단으로 설정합니다.

lib/src이라는 새 디렉터리에 config.dart라는 파일을 만듭니다. 이 파일은 다음 단계에서 더 많은 상수를 얻습니다.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

게임의 너비는 820픽셀, 높이는 1600픽셀입니다. 게임 영역은 표시되는 창에 맞게 배율이 조정되지만 화면에 추가된 모든 구성요소는 이 높이와 너비를 따릅니다.

PlayArea 만들기

브레이크아웃 게임에서는 공이 플레이 영역의 벽에서 튕겨 나갑니다. 충돌을 수용하려면 먼저 PlayArea 구성요소가 필요합니다.

  1. lib/src/components이라는 새 디렉터리에 play_area.dart라는 파일을 만듭니다.
  2. 이 파일에 다음을 추가합니다.

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
      : super(
          paint: Paint()..color = const Color(0xfff2e8cf),
        );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

Flutter에는 Widget가 있는 경우 Flame에는 Component가 있습니다. Flutter 앱이 위젯 트리를 생성하는 것으로 구성되는 반면, Flame 게임은 구성요소 트리를 유지하는 것으로 구성됩니다.

여기에서 Flutter와 Flame에는 흥미로운 차이점이 있습니다. Flutter의 위젯 트리는 영구적이고 변경 가능한 RenderObject 레이어를 업데이트하는 데 사용하도록 빌드된 임시 설명입니다. Flame의 구성요소는 영구적이고 변경 가능합니다. 개발자는 이러한 구성요소를 시뮬레이션 시스템의 일부로 사용할 것으로 예상합니다.

Flame의 구성요소는 게임 메커니즘을 표현하는 데 최적화되어 있습니다. 이 Codelab은 다음 단계에서 소개하는 게임 루프로 시작합니다.

  1. 불필요한 요소를 관리하려면 이 프로젝트의 모든 구성요소가 포함된 파일을 추가하세요. lib/src/componentscomponents.dart 파일을 만들고 다음 콘텐츠를 추가합니다.

lib/src/components/components.dart

export 'play_area.dart';

export 지시어는 import의 역 역할을 합니다. 이 파일을 다른 파일로 가져올 때 노출하는 기능을 선언합니다. 다음 단계에서 새 구성요소를 추가하면 이 파일의 항목이 더 많아집니다.

Flame 게임 만들기

이전 단계에서 빨간색 구불구불한 부분을 없애려면 Flame의 FlameGame의 새 서브클래스를 파생합니다.

  1. lib/srcbrick_breaker.dart라는 파일을 만들고 다음 코드를 추가합니다.

lib/src/brick_breaker.dart

import 'dart:async';

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());
  }
}

이 파일은 게임의 동작을 조정합니다. 이 코드는 게임 인스턴스를 생성하는 동안 고정 해상도 렌더링을 사용하도록 게임을 구성합니다. 게임이 포함된 화면을 채우도록 게임 크기가 조절되고 필요에 따라 레터박스가 추가됩니다.

PlayArea와 같은 하위 구성요소가 적절한 크기로 설정될 수 있도록 게임의 너비와 높이를 노출합니다.

재정의된 onLoad 메서드에서 코드는 두 가지 작업을 실행합니다.

  1. 왼쪽 상단을 뷰파인더의 앵커로 구성합니다. 기본적으로 뷰파인더는 영역의 가운데를 (0,0)의 앵커로 사용합니다.
  2. worldPlayArea를 추가합니다. 세계는 게임 세계를 나타냅니다. 이 클래스는 CameraComponent의 뷰 변환을 통해 모든 하위 요소를 프로젝션합니다.

화면에 게임 표시

이 단계에서 변경한 모든 내용을 보려면 lib/main.dart 파일을 다음 변경사항으로 업데이트합니다.

lib/main.dart

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

import 'src/brick_breaker.dart';                                // Add this import

void main() {
  final game = BrickBreaker();                                  // Modify this line
  runApp(GameWidget(game: game));
}

변경사항을 적용한 후 게임을 다시 시작하세요. 다음 그림과 유사한 게임이 표시됩니다.

앱 창 중앙에 모래색 직사각형이 있는 brick_breaker 애플리케이션 창을 보여주는 스크린샷

다음 단계에서는 세계에 공을 던져 움직입니다.

5. 공 표시

볼 구성요소 만들기

화면에 움직이는 공을 놓는 것은 다른 구성 요소를 만들어 게임 세계에 추가하는 것을 포함합니다.

  1. 다음과 같이 lib/src/config.dart 파일의 콘텐츠를 수정합니다.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

이름이 지정된 상수를 파생 값으로 정의하는 디자인 패턴은 이 Codelab에서 여러 번 반환됩니다. 이를 통해 최상위 gameWidthgameHeight를 수정하여 결과적으로 게임의 디자인과 분위기가 어떻게 바뀌는지 살펴볼 수 있습니다.

  1. lib/src/componentsball.dart 파일에 Ball 구성요소를 만듭니다.

lib/src/components/ball.dart

import 'package:flame/components.dart';
import 'package:flutter/material.dart';

class Ball extends CircleComponent {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill);

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }
}

앞에서 RectangleComponent를 사용하여 PlayArea를 정의했으므로 더 많은 도형이 존재해야 했습니다. RectangleComponent와 마찬가지로 CircleComponentPositionedComponent에서 파생되므로 화면에 공을 배치할 수 있습니다. 무엇보다도 위치를 업데이트할 수 있습니다.

이 구성요소는 velocity라는 개념을 도입하거나 시간 경과에 따라 위치가 변경됩니다. 속도는 속도와 방향 모두이므로 속도는 Vector2 객체입니다. 위치를 업데이트하려면 게임 엔진이 모든 프레임에 호출하는 update 메서드를 재정의합니다. dt는 이전 프레임과 이 프레임 사이의 지속 시간입니다. 이를 통해 다양한 프레임 속도 (60hz 또는 120hz) 또는 과도한 계산으로 인한 긴 프레임과 같은 요인에 적응할 수 있습니다.

position += velocity * dt 업데이트를 주의 깊게 살펴보세요. 이는 시간 경과에 따른 모션의 개별 시뮬레이션 업데이트를 구현하는 방법입니다.

  1. 구성요소 목록에 Ball 구성요소를 포함하려면 다음과 같이 lib/src/components/components.dart 파일을 수정합니다.

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

세상에 공을 담았습니다.

당신은 공을 가지고 있습니다. 객체를 실제 위치에 배치하고 놀이 공간에서 움직이도록 설정해 보겠습니다.

다음과 같이 lib/src/brick_breaker.dart 파일을 수정합니다.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math; // Add this import

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();                                   // Add this variable
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(                                             // Add from here...
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;                                           // To here.
  }
}

이렇게 변경하면 Ball 구성요소가 world에 추가됩니다. 공의 position를 디스플레이 영역의 중앙으로 설정하려면 Vector2에는 스칼라 값으로 Vector2를 조정하는 연산자 오버로드 (*/)가 있으므로 코드에서는 먼저 게임의 크기를 절반으로 줄입니다.

공의 velocity를 설정하려면 더 복잡합니다. 화면에서 공을 임의의 방향으로 적절한 속도로 이동시키는 것이 목적입니다. normalized 메서드를 호출하면 원래 Vector2와 동일한 방향으로 설정된 Vector2 객체가 생성되지만 거리가 1로 축소됩니다. 이렇게 하면 공의 방향에 관계없이 공의 속도가 일정하게 유지됩니다. 그런 다음 공의 속도는 게임 높이의 1/4까지 확장됩니다.

이러한 다양한 값을 올바르게 구현하려면 업계에서 플레이 테스트라고도 하는 몇 가지 반복 작업이 필요합니다.

마지막 줄은 디버깅 디스플레이를 켜서 디버깅을 돕기 위해 디스플레이에 정보를 추가합니다.

이제 게임을 실행하면 다음과 같은 화면이 표시됩니다.

모래 색 직사각형 위에 파란색 원이 있는 brick_breaker 애플리케이션 창을 보여주는 스크린샷 파란색 원에는 화면의 크기와 위치를 나타내는 숫자가 주석으로 표시되어 있습니다.

PlayArea 구성요소와 Ball 구성요소에 모두 디버깅 정보가 있지만 배경 매트는 PlayArea의 숫자를 자릅니다. 모든 항목에 디버깅 정보가 표시되는 이유는 전체 구성요소 트리에 debugMode를 사용 설정했기 때문입니다. 필요한 경우 선택한 구성요소에 대해서만 디버깅을 사용 설정할 수도 있습니다.

게임을 몇 번 다시 시작하면 공이 벽과 정상적으로 상호작용하지 않는 것을 알 수 있습니다. 이러한 효과를 얻으려면 충돌 감지를 추가해야 하며, 이 작업은 다음 단계에서 수행합니다.

6. 튕기기

충돌 감지 추가

충돌 감지는 두 객체가 서로 접촉했을 때 게임에서 인식하는 동작을 추가합니다.

게임에 충돌 감지를 추가하려면 다음 코드와 같이 BrickBreaker 게임에 HasCollisionDetection 믹스인을 추가합니다.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

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

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    debugMode = true;
  }
}

그러면 구성요소의 히트박스를 추적하고 모든 게임 틱에서 충돌 콜백을 트리거합니다.

게임의 히트박스를 채우려면 아래와 같이 PlayArea 구성요소를 수정합니다.

lib/src/components/play_area.dart

import 'dart:async';

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
  PlayArea()
      : super(
          paint: Paint()..color = const Color(0xfff2e8cf),
          children: [RectangleHitbox()],                        // Add this parameter
        );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

RectangleHitbox 구성요소를 RectangleComponent의 하위 요소로 추가하면 상위 구성요소의 크기와 일치하는 충돌 감지를 위한 히트 박스가 구성됩니다. 상위 구성요소보다 더 작거나 큰 히트박스를 원하는 경우를 위해 relative라는 RectangleHitbox의 팩토리 생성자가 있습니다.

공 튕기기

지금까지, 충돌 감지를 추가해도 게임플레이에는 아무런 변화가 없었습니다. Ball 구성요소를 수정하면 변경되지 않습니다. PlayArea와 충돌하면 공의 동작이 변경되어야 합니다.

다음과 같이 Ball 구성요소를 수정합니다.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';                         // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';                                 // And this import
import 'play_area.dart';                                        // And this one too

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {   // Add these mixins
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);                        // Add this parameter

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override                                                     // Add from here...
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        removeFromParent();
      }
    } else {
      debugPrint('collision with $other');
    }
  }                                                             // To here.
}

이 예에서는 onCollisionStart 콜백을 추가하여 큰 변화를 줍니다. 이전 예에서 BrickBreaker에 추가된 충돌 감지 시스템은 이 콜백을 호출합니다.

먼저 코드는 BallPlayArea와 충돌했는지 테스트합니다. 게임 세계에는 다른 구성요소가 없기 때문에 지금은 중복으로 보입니다. 다음 단계에서 이 세상에 박쥐를 추가하면 상황이 달라집니다. 그런 다음 공이 박쥐가 아닌 물체와 충돌할 때 처리하는 else 조건을 추가합니다. 나머지 로직을 구현해야 하는 경우

공이 바닥 벽과 부딪히면 시야에 최대한 남아 있는 상태에서 플레이 표면에서 사라집니다. 이후 단계에서 불꽃 효과의 힘을 사용해 이 아티팩트를 처리합니다.

이제 공이 게임의 벽과 충돌하므로 플레이어에게 공을 칠 수 있는 배트를 주는 것이 유용할 것입니다...

7. 공으로 배트를 치세요

박쥐 만들기

게임 내에서 공이 계속되도록 배트를 추가하려면

  1. 다음과 같이 lib/src/config.dart 파일에 상수를 삽입합니다.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;                               // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;                               // To here.

batHeightbatWidth 상수는 설명이 필요하지 않습니다. 반면에 batStep 상수에는 약간의 설명이 필요합니다. 이 게임에서 공과 상호작용하기 위해 플레이어는 플랫폼에 따라 마우스나 손가락으로 배트를 드래그하거나 키보드를 사용할 수 있습니다. batStep 상수는 왼쪽 또는 오른쪽 화살표 키를 누를 때마다 배트 걸음 수를 구성합니다.

  1. 다음과 같이 Bat 구성요소 클래스를 정의합니다.

lib/src/components/bat.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';

class Bat extends PositionComponent
    with DragCallbacks, HasGameReference<BrickBreaker> {
  Bat({
    required this.cornerRadius,
    required super.position,
    required super.size,
  }) : super(
          anchor: Anchor.center,
          children: [RectangleHitbox()],
        );

  final Radius cornerRadius;

  final _paint = Paint()
    ..color = const Color(0xff1e6091)
    ..style = PaintingStyle.fill;

  @override
  void render(Canvas canvas) {
    super.render(canvas);
    canvas.drawRRect(
        RRect.fromRectAndRadius(
          Offset.zero & size.toSize(),
          cornerRadius,
        ),
        _paint);
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(0, game.width);
  }

  void moveBy(double dx) {
    add(MoveToEffect(
      Vector2((position.x + dx).clamp(0, game.width), position.y),
      EffectController(duration: 0.1),
    ));
  }
}

이 구성요소에는 몇 가지 새로운 기능이 도입되었습니다.

첫째, Bat 구성요소는 RectangleComponent이나 CircleComponent가 아닌 PositionComponent입니다. 즉, 이 코드는 Bat를 화면에 렌더링해야 합니다. 이를 위해 render 콜백을 재정의합니다.

canvas.drawRRect (둥근 직사각형 그리기) 호출을 자세히 살펴보면서 '직사각형은 어디에 있나요?'라고 자문해 볼 수 있습니다. Offset.zero & size.toSize()Rect를 만드는 dart:ui Offset 클래스에서 operator & 오버로드를 활용합니다. 이 약칭은 처음에는 혼란스러울 수 있지만 하위 수준의 Flutter 및 Flame 코드에서는 자주 볼 수 있습니다.

둘째, 이 Bat 구성요소는 플랫폼에 따라 손가락이나 마우스를 사용하여 드래그할 수 있습니다. 이 기능을 구현하려면 DragCallbacks 믹스인을 추가하고 onDragUpdate 이벤트를 재정의합니다.

마지막으로 Bat 구성요소가 키보드 컨트롤에 응답해야 합니다. moveBy 함수를 사용하면 다른 코드는 이 배트가 특정 가상 픽셀 수만큼 왼쪽이나 오른쪽으로 이동하도록 지시할 수 있습니다. 이 함수는 Flame 게임 엔진의 새로운 기능인 Effect를 도입합니다. MoveToEffect 객체를 이 구성요소의 하위 요소로 추가하면 배트가 새로운 위치로 애니메이션 처리됩니다. Flame에는 다양한 효과를 실행하는 데 사용할 수 있는 Effect 컬렉션이 있습니다.

효과의 생성자 인수에는 game getter 참조가 포함됩니다. 따라서 이 클래스에 HasGameReference 믹스인을 포함합니다. 이 믹스인은 유형 안전 game 접근자를 이 구성요소에 추가하여 구성요소 트리 상단의 BrickBreaker 인스턴스에 액세스합니다.

  1. BatBrickBreaker에서 사용할 수 있도록 하려면 다음과 같이 lib/src/components/components.dart 파일을 업데이트합니다.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

세상에 박쥐 추가하기

Bat 구성요소를 게임 세계에 추가하려면 다음과 같이 BrickBreaker를 업데이트하세요.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';                             // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart';                         // And this import
import 'package:flutter/services.dart';                         // And this

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {                // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(                                              // Add from here...
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));          // To here

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here
}

KeyboardEvents 믹스인과 재정의된 onKeyEvent 메서드를 추가하면 키보드 입력이 처리됩니다. 앞에서 추가한 코드를 떠올려 배트를 적절한 보폭만큼 이동합니다.

추가된 나머지 코드 청크는 적절한 위치와 비율로 게임 세계에 배트를 추가합니다. 이 파일에 이러한 설정을 모두 표시하면 배트와 공의 상대적인 크기를 조정하여 경기에 맞는 느낌을 주는 것이 간단해집니다.

이 시점에서 게임을 플레이하면 배트를 움직여 공을 가로챌 수 있지만 Ball의 충돌 감지 코드에 남긴 디버그 로깅 외에 눈에 보이는 응답은 표시되지 않습니다.

이제 문제를 해결할 시간입니다. 다음과 같이 Ball 구성요소를 수정합니다.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';                           // Add this import
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';                                             // And this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(                                       // Modify from here...
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

이러한 코드 변경으로 두 개의 개별 문제가 해결됩니다.

첫째, 화면 하단에 터치하는 순간 공이 사라지는 문제를 수정합니다. 이 문제를 해결하려면 removeFromParent 호출을 RemoveEffect로 바꿉니다. RemoveEffect는 공이 조회 가능한 플레이 영역을 벗어나게 한 후 게임 세계에서 공을 삭제합니다.

둘째, 이러한 변경을 통해 배트와 공 간의 충돌 처리 문제가 해결되었습니다. 이 처리 코드는 플레이어에게 유리하게 작동합니다. 플레이어가 방망이로 공을 터치하면 공이 화면 상단으로 돌아갑니다. 너무 관대하게 느껴지고 좀 더 현실적인 것을 원한다면 게임을 원하는 느낌에 더 잘 맞게 핸들링을 변경하세요.

velocity 업데이트의 복잡성을 짚어볼 필요가 있습니다. 벽 충돌 시처럼 속도의 y 구성요소를 반전시키는 것만이 아닙니다. 또한 접촉 시 배트와 공의 상대적 위치에 따라 x 구성요소를 업데이트합니다. 이를 통해 플레이어는 공이 하는 동작을 더 세밀하게 제어할 수 있지만 플레이를 제외하고 어떤 식으로든 플레이어에게 전달되는 방법은 정확히 없습니다.

이제 공을 칠 수 있는 배트가 있으므로 공을 칠 수 있는 벽돌을 몇 개 만드는 것이 좋습니다.

8. 장벽 허물기

벽돌 만들기

게임에 블록을 추가하려면

  1. 다음과 같이 lib/src/config.dart 파일에 상수를 삽입합니다.

lib/src/config.dart

import 'package:flutter/material.dart';                         // Add this import

const brickColors = [                                           // Add this const
  Color(0xfff94144),
  Color(0xfff3722c),
  Color(0xfff8961e),
  Color(0xfff9844a),
  Color(0xfff9c74f),
  Color(0xff90be6d),
  Color(0xff43aa8b),
  Color(0xff4d908e),
  Color(0xff277da1),
  Color(0xff577590),
];

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015;                          // Add from here...
final brickWidth =
    (gameWidth - (brickGutter * (brickColors.length + 1)))
    / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03;                                // To here.
  1. 다음과 같이 Brick 구성요소를 삽입합니다.

lib/src/components/brick.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

이제 이 코드는 대부분 익숙할 것입니다. 이 코드는 충돌 감지 및 구성요소 트리 상단에 있는 BrickBreaker 게임에 관한 유형 안전 참조와 함께 RectangleComponent를 사용합니다.

이 코드에서 도입하는 가장 중요한 새 개념은 플레이어가 승리 조건을 달성하는 방법입니다. 낙찰 조건 확인은 전 세계에 벽돌을 쿼리하여 벽돌이 하나만 남았는지 확인합니다. 앞의 행이 상위 요소에서 이 블록을 삭제하므로 다소 혼란스러울 수 있습니다.

이해해야 할 핵심 사항은 구성요소 삭제가 큐에 추가된 명령어라는 것입니다. 이 코드가 실행된 후, 게임 세계의 다음 틱 전에 블록을 제거합니다.

BrickBreaker에서 Brick 구성요소에 액세스할 수 있도록 하려면 다음과 같이 lib/src/components/components.dart를 수정합니다.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

블록 추가

다음과 같이 Ball 구성요소를 업데이트합니다.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';                                            // Add this import
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,                           // Add this parameter
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;
  final double difficultyModifier;                              // Add this member

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {                                // Modify from here...
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);          // To here.
    }
  }
}

블록이 충돌할 때마다 공의 속도가 높아지는 난이도 수정자인 유일한 새로운 측면이 도입되었습니다. 조정 가능한 매개변수를 플레이하여 게임에 적합한 난이도 곡선을 찾아야 합니다.

다음과 같이 BrickBreaker 게임을 수정합니다.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    world.add(Ball(
        difficultyModifier: difficultyModifier,                 // Add this argument
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    await world.addAll([                                        // Add from here...
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);                                                         // To here.

    debugMode = true;
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }
}

현재 상태로 게임을 실행하면 모든 주요 게임 메커니즘이 표시됩니다. 디버깅을 끄고 완료로 표시할 수 있지만 뭔가 누락된 것 같습니다.

공과 배트가 있는 brick_breaker와 경기장의 대부분의 벽돌을 보여주는 스크린샷 각 구성요소에는 디버깅 라벨이 있습니다.

시작 화면, 게임 오버 화면, 점수는 어떨까요? Flutter는 이러한 기능을 게임에 추가할 수 있으며, 거기서 주의를 돌리게 됩니다.

9. 게임에서 이기기

재생 상태 추가

이 단계에서는 Flutter 래퍼 내부에 Flame 게임을 삽입한 다음 환영 화면, 게임 종료 화면, 획득 화면에 사용할 Flutter 오버레이를 추가합니다.

먼저, 게임과 구성요소 파일을 수정하여 오버레이 표시 여부와 오버레이 표시 여부를 반영하는 플레이 상태를 구현합니다.

  1. 다음과 같이 BrickBreaker 게임을 수정합니다.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }              // Add this enumeration

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {   // Modify this line
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;                                    // Add from here...
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }                                                             // To here.

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;                              // Add from here...
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;                              // To here.

    world.add(Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    world.addAll([                                              // Drop the await
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }                                                             // Drop the debugMode

  @override                                                     // Add from here...
  void onTap() {
    super.onTap();
    startGame();
  }                                                             // To here.

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:                            // Add from here...
      case LogicalKeyboardKey.enter:
        startGame();                                            // To here.
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);          // Add this override
}

이 코드는 BrickBreaker 게임을 상당 부분 변경합니다. playState 열거를 추가하려면 많은 작업이 필요합니다. 플레이어가 들어가거나, 플레이하거나, 게임에서 지고 또는 승리하는 상황을 포착합니다. 파일 상단에서 열거형을 정의한 다음 일치하는 getter와 setter를 사용하여 숨겨진 상태로 인스턴스화합니다. 이러한 getter와 setter를 사용하면 게임의 다양한 부분이 플레이 상태 전환 시 오버레이를 수정할 수 있습니다.

다음으로, onLoad의 코드를 onLoad 및 새 startGame 메서드로 분할합니다. 이번 변경사항이 적용되기 전에는 게임을 다시 시작해야만 새 게임을 시작할 수 있었습니다. 이렇게 새롭게 추가함으로써 플레이어는 이제 그렇게 과감한 조치 없이도 새 게임을 시작할 수 있습니다.

플레이어가 새 게임을 시작할 수 있도록 게임에 새로운 핸들러 2개를 구성했습니다. 사용자가 여러 모달로 새 게임을 시작할 수 있도록 탭 핸들러를 추가하고 키보드 핸들러를 확장했습니다. 플레이 상태를 모델링하면 플레이어가 승리하거나 패배할 때 플레이 상태 전환을 트리거하도록 구성요소를 업데이트하는 것이 합리적입니다.

  1. 다음과 같이 Ball 구성요소를 수정합니다.

lib/src/components/ball.dart

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';

class Ball extends CircleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Ball({
    required this.velocity,
    required super.position,
    required double radius,
    required this.difficultyModifier,
  }) : super(
            radius: radius,
            anchor: Anchor.center,
            paint: Paint()
              ..color = const Color(0xff1e6091)
              ..style = PaintingStyle.fill,
            children: [CircleHitbox()]);

  final Vector2 velocity;
  final double difficultyModifier;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(RemoveEffect(
            delay: 0.35,
            onComplete: () {                                    // Modify from here
              game.playState = PlayState.gameOver;
            }));                                                // To here.
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Brick) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * difficultyModifier);
    }
  }
}

이렇게 조금만 변경하면 RemoveEffectonComplete 콜백이 추가되어 gameOver 재생 상태가 트리거됩니다. 플레이어가 공을 화면 하단에서 벗어날 수 있도록 하면 딱 적당한 것처럼 느껴집니다.

  1. 다음과 같이 Brick 구성요소를 수정합니다.

lib/src/components/brick.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

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

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

반면에 플레이어가 벽돌을 모두 부수면 '게임 이겼다'가 됩니다. 화면 잘하셨습니다. 잘하셨습니다.

Flutter 래퍼 추가

게임을 삽입하고 플레이 상태 오버레이를 추가할 위치를 제공하려면 Flutter 셸을 추가합니다.

  1. lib/src 아래에 widgets 디렉터리를 만듭니다.
  2. game_app.dart 파일을 추가하고 이 파일에 다음 콘텐츠를 삽입합니다.

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';

class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                              child: Text(
                                'TAP TO PLAY',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.gameOver.name: (context, game) => Center(
                              child: Text(
                                'G A M E   O V E R',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.won.name: (context, game) => Center(
                              child: Text(
                                'Y O U   W O N ! ! !',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

이 파일의 콘텐츠는 대부분 표준 Flutter 위젯 트리 빌드를 따릅니다. Flame과 관련된 부분으로는 GameWidget.controlled를 사용하여 BrickBreaker 게임 인스턴스를 구성하고 관리하는 방법과 GameWidget의 새 overlayBuilderMap 인수를 구성하는 것이 포함됩니다.

overlayBuilderMap의 키는 BrickBreakerplayState setter가 추가하거나 삭제한 오버레이와 일치해야 합니다. 이 지도에 없는 오버레이를 설정하려고 하면 주변 면이 불만족스러워집니다.

  1. 이 새로운 기능을 화면에 표시하려면 lib/main.dart 파일을 다음 콘텐츠로 바꿉니다.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
  runApp(const GameApp());
}

iOS, Linux, Windows 또는 웹에서 이 코드를 실행하면 의도한 출력이 게임에 표시됩니다. macOS 또는 Android를 타겟팅하는 경우 마지막으로 조정하여 google_fonts가 표시되도록 해야 합니다.

글꼴 액세스 사용 설정

Android용 인터넷 권한 추가하기

Android의 경우 인터넷 권한을 추가해야 합니다. 다음과 같이 AndroidManifest.xml를 수정합니다.

android/app/src/main/AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Add the following line -->
    <uses-permission android:name="android.permission.INTERNET" />
    <application
        android:label="brick_breaker"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <!-- Specifies an Android theme to apply to this Activity as soon as
                 the Android process has started. This theme is visible to the user
                 while the Flutter UI initializes. After that, this theme continues
                 to determine the Window background behind the Flutter UI. -->
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <!-- Don't delete the meta-data below.
             This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
    <!-- Required to query activities that can process text, see:
         https://developer.android.com/training/package-visibility and
         https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.

         In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
    <queries>
        <intent>
            <action android:name="android.intent.action.PROCESS_TEXT"/>
            <data android:mimeType="text/plain"/>
        </intent>
    </queries>
</manifest>

macOS용 사용 권한 파일 수정

macOS의 경우 수정할 파일이 두 개 있습니다.

  1. 다음 코드와 일치하도록 DebugProfile.entitlements 파일을 수정합니다.

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>
  1. 다음 코드와 일치하도록 Release.entitlements 파일을 수정합니다.

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add from here... -->
        <key>com.apple.security.network.client</key>
        <true/>
        <!-- to here. -->
</dict>
</plist>

이 코드를 그대로 실행하면 모든 플랫폼에서 시작 화면과 게임 종료 또는 승리 화면이 표시되어야 합니다. 이러한 화면은 약간 단순할 수 있으므로 점수가 있으면 좋습니다. 그럼 다음 단계에서 어떻게 할지 생각해 보세요.

10. 점수 유지

게임에 점수 추가

이 단계에서는 게임 점수를 주변 Flutter 컨텍스트에 노출합니다. 이 단계에서는 Flame 게임의 상태를 주변 Flutter 상태 관리에 노출합니다. 이렇게 하면 플레이어가 벽돌을 깨뜨릴 때마다 게임 코드가 점수를 업데이트할 수 있습니다.

  1. 다음과 같이 BrickBreaker 게임을 수정합니다.

lib/src/brick_breaker.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'components/components.dart';
import 'config.dart';

enum PlayState { welcome, playing, gameOver, won }

class BrickBreaker extends FlameGame
    with HasCollisionDetection, KeyboardEvents, TapDetector {
  BrickBreaker()
      : super(
          camera: CameraComponent.withFixedResolution(
            width: gameWidth,
            height: gameHeight,
          ),
        );

  final ValueNotifier<int> score = ValueNotifier(0);            // Add this line
  final rand = math.Random();
  double get width => size.x;
  double get height => size.y;

  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.welcome:
      case PlayState.gameOver:
      case PlayState.won:
        overlays.add(playState.name);
      case PlayState.playing:
        overlays.remove(PlayState.welcome.name);
        overlays.remove(PlayState.gameOver.name);
        overlays.remove(PlayState.won.name);
    }
  }

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

    camera.viewfinder.anchor = Anchor.topLeft;

    world.add(PlayArea());

    playState = PlayState.welcome;
  }

  void startGame() {
    if (playState == PlayState.playing) return;

    world.removeAll(world.children.query<Ball>());
    world.removeAll(world.children.query<Bat>());
    world.removeAll(world.children.query<Brick>());

    playState = PlayState.playing;
    score.value = 0;                                            // Add this line

    world.add(Ball(
        difficultyModifier: difficultyModifier,
        radius: ballRadius,
        position: size / 2,
        velocity: Vector2((rand.nextDouble() - 0.5) * width, height * 0.2)
            .normalized()
          ..scale(height / 4)));

    world.add(Bat(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));

    world.addAll([
      for (var i = 0; i < brickColors.length; i++)
        for (var j = 1; j <= 5; j++)
          Brick(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * brickGutter,
              (j + 2.0) * brickHeight + j * brickGutter,
            ),
            color: brickColors[i],
          ),
    ]);
  }

  @override
  void onTap() {
    super.onTap();
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => const Color(0xfff2e8cf);
}

게임에 score를 추가하면 게임의 상태가 Flutter 상태 관리에 연결됩니다.

  1. Brick 클래스를 수정하여 플레이어가 벽돌을 깨면 점수에 점수를 추가합니다.

lib/src/components/brick.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    game.score.value++;                                         // Add this line

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

멋진 게임 만들기

이제 Flutter에서 점수를 유지할 수 있으므로 위젯을 모아 보기 좋게 만들어 보겠습니다.

  1. lib/src/widgetsscore_card.dart를 만들고 다음을 추가합니다.

lib/src/widgets/score_card.dart

import 'package:flutter/material.dart';

class ScoreCard extends StatelessWidget {
  const ScoreCard({
    super.key,
    required this.score,
  });

  final ValueNotifier<int> score;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: score,
      builder: (context, score, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Score: $score'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}
  1. lib/src/widgetsoverlay_screen.dart를 만들고 다음 코드를 추가합니다.

이렇게 하면 flutter_animate 패키지의 기능을 사용하여 오버레이에 세련미를 더하고 오버레이 화면에 움직임과 스타일을 더할 수 있습니다.

lib/src/widgets/overlay_screen.dart

import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';

class OverlayScreen extends StatelessWidget {
  const OverlayScreen({
    super.key,
    required this.title,
    required this.subtitle,
  });

  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.headlineLarge,
          ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
          const SizedBox(height: 16),
          Text(
            subtitle,
            style: Theme.of(context).textTheme.headlineSmall,
          )
              .animate(onPlay: (controller) => controller.repeat())
              .fadeIn(duration: 1.seconds)
              .then()
              .fadeOut(duration: 1.seconds),
        ],
      ),
    );
  }
}

flutter_animate의 기능을 더 자세히 알아보려면 Flutter에서 차세대 UI 빌드 Codelab을 확인하세요.

이 코드는 GameApp 구성요소에서 많이 변경되었습니다. 먼저 ScoreCardscore에 액세스할 수 있도록 하려면 StatelessWidget에서 StatefulWidget로 변환합니다. 점수 카드를 추가하려면 Column를 추가하여 게임 위에 점수를 쌓아야 합니다.

둘째, 환영, 게임 종료, 승리 경험을 개선하기 위해 새 OverlayScreen 위젯을 추가했습니다.

lib/src/widgets/game_app.dart

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

import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart';                                   // Add this import
import 'score_card.dart';                                       // And this one too

class GameApp extends StatefulWidget {                          // Modify this line
  const GameApp({super.key});

  @override                                                     // Add from here...
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final BrickBreaker game;

  @override
  void initState() {
    super.initState();
    game = BrickBreaker();
  }                                                             // To here.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

모든 것이 준비되면 이제 이 게임을 6개의 Flutter 타겟 플랫폼에서 실행할 수 있습니다. 게임은 다음과 유사합니다.

사용자가 화면을 탭하여 게임을 플레이하도록 초대하는 게임 전 화면을 보여주는 brick_breaker의 스크린샷

배트와 벽돌 일부 위에 오버레이된 화면 오버 게임을 보여주는 brick_breaker의 스크린샷

11. 축하합니다

축하합니다. Flutter와 Flame을 사용하여 게임을 빌드하는 데 성공했습니다.

Flame 2D 게임 엔진을 사용하여 게임을 빌드하고 Flutter 래퍼에 삽입했습니다. Flame의 효과를 사용하여 구성요소에 애니메이션을 적용하고 삭제했습니다. Google Fonts와 Flutter Animate 패키지를 사용하여 전체 게임이 잘 디자인된 것처럼 보이게 했습니다.

다음 단계

다음 Codelab을 확인하세요.

추가 자료