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는 여러 플랫폼을 위한 앱을 생성합니다. 앱이 다음 운영체제 어디서든 실행될 수 있습니다.
- 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에서는 다음 소프트웨어를 설치합니다.
- Flutter SDK
- Flutter 플러그인이 있는 Visual Studio Code
- 선택한 개발 타겟의 컴파일러 소프트웨어입니다. Windows를 타겟팅하려면 Visual Studio가 필요하며 macOS 또는 iOS를 타겟팅하려면 Xcode가 필요합니다.
다음 섹션에서는 첫 번째 Flutter 프로젝트를 만들어 봅니다.
문제를 해결해야 하는 경우 문제 해결에 도움이 되는 다음 질문과 답변 (StackOverflow에서 제공)이 도움이 될 수 있습니다.
자주 묻는 질문(FAQ)
- Flutter SDK 경로는 어떻게 찾을 수 있나요?
- Flutter 명령어를 찾을 수 없으면 어떻게 해야 하나요?
- '시작 잠금을 해제하기 위해 다른 Flutter 명령어를 기다리는 중' 문제를 해결하려면 어떻게 해야 하나요?
- Flutter에 Android SDK 설치 위치를 알리려면 어떻게 해야 하나요?
flutter doctor --android-licenses
를 실행할 때 Java 오류를 처리하려면 어떻게 해야 하나요?- 찾을 수 없는
sdkmanager
Android 도구는 어떻게 처리해야 하나요? - '
cmdline-tools
구성요소가 누락됨' 오류는 어떻게 처리해야 하나요? - Apple Silicon(M1)에서 CocoaPods를 실행하려면 어떻게 해야 하나요?
- VS Code에서 저장 시 자동 형식 지정을 사용 중지하려면 어떻게 해야 하나요?
3. 프로젝트 만들기
첫 번째 Flutter 프로젝트 만들기
이때 VS Code를 열고 선택한 디렉터리에 Flutter 앱 템플릿을 만들어야 합니다.
- Visual Studio Code를 실행합니다.
- 명령어 팔레트 (
F1
또는Ctrl+Shift+P
또는Shift+Cmd+P
)를 열고 'flutter new'를 입력합니다. 메뉴가 표시되면 Flutter: New Project 명령어를 선택합니다.
- Empty Application을 선택합니다. 프로젝트를 만들 디렉터리를 선택합니다. 승격된 권한이 필요하지 않거나 경로에 공백이 있는 디렉터리여야 합니다. 예를 들어 홈 디렉터리 또는
C:\src\
등이 있습니다.
- 프로젝트 이름을
brick_breaker
로 지정합니다. 이 Codelab의 나머지 부분에서는 앱의 이름을brick_breaker
로 지정했다고 가정합니다.
이제 Flutter에서 프로젝트 폴더를 생성하고 VS Code에서 이 폴더를 엽니다. 이제 앱의 기본 스캐폴드로 두 파일의 콘텐츠를 덮어씁니다.
초기 앱 복사 및 붙여넣기
이렇게 하면 이 Codelab에서 제공한 예시 코드가 앱에 추가됩니다.
- VS Code의 왼쪽 창에서 Explorer를 클릭하고
pubspec.yaml
파일을 엽니다.
- 이 파일의 콘텐츠를 다음으로 바꿉니다.
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
파일은 앱에 관한 기본 정보(예: 현재 버전, 종속 항목, 함께 제공될 애셋)를 지정합니다.
lib/
디렉터리에서main.dart
파일을 엽니다.
- 이 파일의 콘텐츠를 다음으로 바꿉니다.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- 이 코드를 실행하여 모든 것이 제대로 작동하는지 확인합니다. 그러면 빈 검은색 배경만 있는 새 창이 표시됩니다. 세계 최악의 비디오 게임이 현재 60fps로 렌더링되고 있습니다.
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
구성요소가 필요합니다.
lib/src/components
이라는 새 디렉터리에play_area.dart
라는 파일을 만듭니다.- 이 파일에 다음을 추가합니다.
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은 다음 단계에서 소개하는 게임 루프로 시작합니다.
- 불필요한 요소를 관리하려면 이 프로젝트의 모든 구성요소가 포함된 파일을 추가하세요.
lib/src/components
에components.dart
파일을 만들고 다음 콘텐츠를 추가합니다.
lib/src/components/components.dart
export 'play_area.dart';
export
지시어는 import
의 역 역할을 합니다. 이 파일을 다른 파일로 가져올 때 노출하는 기능을 선언합니다. 다음 단계에서 새 구성요소를 추가하면 이 파일의 항목이 더 많아집니다.
Flame 게임 만들기
이전 단계에서 빨간색 구불구불한 부분을 없애려면 Flame의 FlameGame
의 새 서브클래스를 파생합니다.
lib/src
에brick_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
메서드에서 코드는 두 가지 작업을 실행합니다.
- 왼쪽 상단을 뷰파인더의 앵커로 구성합니다. 기본적으로 뷰파인더는 영역의 가운데를
(0,0)
의 앵커로 사용합니다. world
에PlayArea
를 추가합니다. 세계는 게임 세계를 나타냅니다. 이 클래스는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));
}
변경사항을 적용한 후 게임을 다시 시작하세요. 다음 그림과 유사한 게임이 표시됩니다.
다음 단계에서는 세계에 공을 던져 움직입니다.
5. 공 표시
볼 구성요소 만들기
화면에 움직이는 공을 놓는 것은 다른 구성 요소를 만들어 게임 세계에 추가하는 것을 포함합니다.
- 다음과 같이
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에서 여러 번 반환됩니다. 이를 통해 최상위 gameWidth
및 gameHeight
를 수정하여 결과적으로 게임의 디자인과 분위기가 어떻게 바뀌는지 살펴볼 수 있습니다.
lib/src/components
의ball.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
와 마찬가지로 CircleComponent
는 PositionedComponent
에서 파생되므로 화면에 공을 배치할 수 있습니다. 무엇보다도 위치를 업데이트할 수 있습니다.
이 구성요소는 velocity
라는 개념을 도입하거나 시간 경과에 따라 위치가 변경됩니다. 속도는 속도와 방향 모두이므로 속도는 Vector2
객체입니다. 위치를 업데이트하려면 게임 엔진이 모든 프레임에 호출하는 update
메서드를 재정의합니다. dt
는 이전 프레임과 이 프레임 사이의 지속 시간입니다. 이를 통해 다양한 프레임 속도 (60hz 또는 120hz) 또는 과도한 계산으로 인한 긴 프레임과 같은 요인에 적응할 수 있습니다.
position += velocity * dt
업데이트를 주의 깊게 살펴보세요. 이는 시간 경과에 따른 모션의 개별 시뮬레이션 업데이트를 구현하는 방법입니다.
- 구성요소 목록에
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까지 확장됩니다.
이러한 다양한 값을 올바르게 구현하려면 업계에서 플레이 테스트라고도 하는 몇 가지 반복 작업이 필요합니다.
마지막 줄은 디버깅 디스플레이를 켜서 디버깅을 돕기 위해 디스플레이에 정보를 추가합니다.
이제 게임을 실행하면 다음과 같은 화면이 표시됩니다.
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
에 추가된 충돌 감지 시스템은 이 콜백을 호출합니다.
먼저 코드는 Ball
가 PlayArea
와 충돌했는지 테스트합니다. 게임 세계에는 다른 구성요소가 없기 때문에 지금은 중복으로 보입니다. 다음 단계에서 이 세상에 박쥐를 추가하면 상황이 달라집니다. 그런 다음 공이 박쥐가 아닌 물체와 충돌할 때 처리하는 else
조건을 추가합니다. 나머지 로직을 구현해야 하는 경우
공이 바닥 벽과 부딪히면 시야에 최대한 남아 있는 상태에서 플레이 표면에서 사라집니다. 이후 단계에서 불꽃 효과의 힘을 사용해 이 아티팩트를 처리합니다.
이제 공이 게임의 벽과 충돌하므로 플레이어에게 공을 칠 수 있는 배트를 주는 것이 유용할 것입니다...
7. 공으로 배트를 치세요
박쥐 만들기
게임 내에서 공이 계속되도록 배트를 추가하려면
- 다음과 같이
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.
batHeight
및 batWidth
상수는 설명이 필요하지 않습니다. 반면에 batStep
상수에는 약간의 설명이 필요합니다. 이 게임에서 공과 상호작용하기 위해 플레이어는 플랫폼에 따라 마우스나 손가락으로 배트를 드래그하거나 키보드를 사용할 수 있습니다. batStep
상수는 왼쪽 또는 오른쪽 화살표 키를 누를 때마다 배트 걸음 수를 구성합니다.
- 다음과 같이
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
인스턴스에 액세스합니다.
Bat
를BrickBreaker
에서 사용할 수 있도록 하려면 다음과 같이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. 장벽 허물기
벽돌 만들기
게임에 블록을 추가하려면
- 다음과 같이
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.
- 다음과 같이
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;
}
}
현재 상태로 게임을 실행하면 모든 주요 게임 메커니즘이 표시됩니다. 디버깅을 끄고 완료로 표시할 수 있지만 뭔가 누락된 것 같습니다.
시작 화면, 게임 오버 화면, 점수는 어떨까요? Flutter는 이러한 기능을 게임에 추가할 수 있으며, 거기서 주의를 돌리게 됩니다.
9. 게임에서 이기기
재생 상태 추가
이 단계에서는 Flutter 래퍼 내부에 Flame 게임을 삽입한 다음 환영 화면, 게임 종료 화면, 획득 화면에 사용할 Flutter 오버레이를 추가합니다.
먼저, 게임과 구성요소 파일을 수정하여 오버레이 표시 여부와 오버레이 표시 여부를 반영하는 플레이 상태를 구현합니다.
- 다음과 같이
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개를 구성했습니다. 사용자가 여러 모달로 새 게임을 시작할 수 있도록 탭 핸들러를 추가하고 키보드 핸들러를 확장했습니다. 플레이 상태를 모델링하면 플레이어가 승리하거나 패배할 때 플레이 상태 전환을 트리거하도록 구성요소를 업데이트하는 것이 합리적입니다.
- 다음과 같이
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);
}
}
}
이렇게 조금만 변경하면 RemoveEffect
에 onComplete
콜백이 추가되어 gameOver
재생 상태가 트리거됩니다. 플레이어가 공을 화면 하단에서 벗어날 수 있도록 하면 딱 적당한 것처럼 느껴집니다.
- 다음과 같이
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 셸을 추가합니다.
lib/src
아래에widgets
디렉터리를 만듭니다.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
의 키는 BrickBreaker
의 playState
setter가 추가하거나 삭제한 오버레이와 일치해야 합니다. 이 지도에 없는 오버레이를 설정하려고 하면 주변 면이 불만족스러워집니다.
- 이 새로운 기능을 화면에 표시하려면
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의 경우 수정할 파일이 두 개 있습니다.
- 다음 코드와 일치하도록
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>
- 다음 코드와 일치하도록
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 상태 관리에 노출합니다. 이렇게 하면 플레이어가 벽돌을 깨뜨릴 때마다 게임 코드가 점수를 업데이트할 수 있습니다.
- 다음과 같이
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 상태 관리에 연결됩니다.
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에서 점수를 유지할 수 있으므로 위젯을 모아 보기 좋게 만들어 보겠습니다.
lib/src/widgets
에score_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!,
),
);
},
);
}
}
lib/src/widgets
에overlay_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
구성요소에서 많이 변경되었습니다. 먼저 ScoreCard
가 score
에 액세스할 수 있도록 하려면 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 타겟 플랫폼에서 실행할 수 있습니다. 게임은 다음과 유사합니다.
11. 축하합니다
축하합니다. Flutter와 Flame을 사용하여 게임을 빌드하는 데 성공했습니다.
Flame 2D 게임 엔진을 사용하여 게임을 빌드하고 Flutter 래퍼에 삽입했습니다. Flame의 효과를 사용하여 구성요소에 애니메이션을 적용하고 삭제했습니다. Google Fonts와 Flutter Animate 패키지를 사용하여 전체 게임이 잘 디자인된 것처럼 보이게 했습니다.
다음 단계
다음 Codelab을 확인하세요.