일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
31 |
- Image Processing
- Widget
- Stream
- PCA
- BOF
- fastapi를 사용한 파이썬 웹 개발
- Kaggle
- 백준
- bloc
- 영상처리
- 파이토치 트랜스포머를 활용한 자연어 처리와 컴퓨터비전 심층학습
- DART
- FastAPI
- system hacking
- rao
- ML
- MDP
- Got
- MATLAB
- Dreamhack
- BAEKJOON
- ARM
- llm을 활용 단어장 앱 개발일지
- Computer Architecture
- C++
- pytorch
- study book
- BFS
- Flutter
- Algorithm
- Today
- Total
Bull
[Flutter::flame] Flame 엔진을 이용하여 구글의 공룡 게임을 만들어 보자! 본문
[Flutter::flame] Flame 엔진을 이용하여 구글의 공룡 게임을 만들어 보자!
Bull_ 2024. 8. 7. 20:33서론
원래는 테트리스를 GPT를 이용해서 빠르게 만드려다가 생각보다 시간이 걸릴 거 같아서 연습 삼아 구글의 공룡게임을 만들게 되었습니다. flame에 대해서는 거의 모르는 상태입니다.
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
끽해야 flame을 실행하려면 첫 시작을 위처럼 코드를 작성해야 한다는 정도로 알고 있는데요. 얄팍한 지식이지만 설명을 하면서 어떻게 동작하는 지 설명해보겠습니다.
특히 배경에 대해서 완전한 마무리를 지은 것이 아니라서 그거에 대해 양해를 구하고 설명해보겠습니다.
소스코드와 에셋은 제일 아래에 제공해놨으니 참고하세요!
assets
먼저 게임을 만들려면 에셋이 필요합니다. 공룡 게임을 만들기 위해서 공룔과 장애물은 반드시 필요한데요. 배경은 반드시 필요하지 않지만 assets을 추가하는 과정에서 배경이 없으니 밋밋한 느낌이 들어서 배경도 추가하였습니다.
assets 만들기
유니티 같은 경우는 에셋 스토어가 따로 존재하고 플레임은 잘 모르겠지만 유니티 에셋스토어에서 가져와서 사용해도 됩니다. 하지만 전 간단하게 만들고 싶어서 chatGPT를 통해 일단 물어보는 시도를 해봤습니다.
wow! 배경이 투명한 png 파일이었으면 좋았겠지만 어쨌든 리소스 파일 확보는 성공했습니다! chatGPT는 webp 확장자로 이미지를 제공해주기 때문에 webp to png 를 제공해주는 사이트에서 변환을 진행해보겠습니다.
변환된 png 파일을 2개로 복사하여서 공룡 사진과 선인장 사진을 나눠주면 됩니다. 그러면 아직 배경색이 남게 되는데요. png파일의 배경을 투명하게 만들어 주는 사이트 또한 존재하네요.
잘라낸 공룡 사진과 선인장 사진을 위 사이트에서 변경해보겠습니다.
이런 걸 누끼 딴다고 하더라고요. 그런데 약간의 픽셀이 남았고 오른쪽에 테두리가 약간 잘려나갔지만 그대로 사용해줍니다. 이제 파일이름을 dino.png 와 cactus.png로 만들고 assets/images/ 폴더에 넣도록 하겠습니다.
그리고 배경 이미지도 잊지 말아주세요. 같은 방식으로 반들어서 assets/images/ 폴더에 넣어 줍니다.
배경에 픽셀이 깨진 것이 조금 아쉽지만 넘어가겠습니다...
Dino Game
이제 본격적으로 CODE 를 단계적으로 풀어나가며 설명해보겠습니다. 그전에 다음과 같이 asset과 flame 패키지를 yaml 파일을 통해서 등록해주고 파일을 나눠서 만들어보겠습니다.
dependencies:
flutter:
sdk: flutter
flame: ^1.18.0
flutter:
assets:
- assets/images/dino.png
- assets/images/cactus.png
- assets/images/background.png
파일은 다음과 같이 만들어줍니다.
lib
├─ components
│ ├─ dino.dart
│ ├─ obstacle_manager.dart
│ └─ obstacle.dart
├─ game
│ └─ dino_game.dart
├─ pages
│ └─ game_page.dart
├─ widgets
│ └─ game_over_dialog.dart
└─ main.dart
flame 실행
처음에 이해를 돕기 위해서는 하나의 파일 안에다가 모두 구현하는 것이 좋지만 설명을 위해 리팩터를 먼저 하고 설명을 하겠습니다.
// filename: main.dart
import 'package:flutter/material.dart';
import 'pages/game_page.dart';
void main() async {
runApp(MaterialApp(
home: DinoGamePage(),
));
}
main 에는 gamePage 를 먼저 렌더링 해줍니다. 여기서는 아직 flame이 사용되지 않았습니다.
// filename: game/dino_game.dart
import 'package:flame/game.dart';
class DinoGame extends FlameGame {
@override
void onLoad() {}
void reset() {}
}
공룡 게임이 시작되는 부분을 정의해줍니다. 여기서 FlameGame
을 상속받았습니다. 왜냐하면 앞서 설명할 DinoGamePage
위젯에서 flame을 실행하기 위해서는 FlameGame
클래스를 game 프로퍼티에 등록해주어야 하기 때문입니다.
onLoad
메소드는 FlameGame
에 메소드를 상속받아서 사용합니다. 이 부분은 게임이 시작될 때 각종 컴포넌트 등을 초기화하고 진행을 하는 부분을 정의합니다. reset
메소드는 게임이 끝났을 때 초기화해주는 역할을 구현합니다. 게임이 끝나는 로직에 따라 이 메소드를 호출합니다.
// filename: pages/game_page.dart
import 'package:flutter/material.dart';
import 'package:flame/game.dart';
import '../game/dino_game.dart';
class DinoGamePage extends StatefulWidget {
@override
_DinoGamePageState createState() => _DinoGamePageState();
}
class _DinoGamePageState extends State<DinoGamePage> {
late DinoGame game;
@override
void initState() {
super.initState();
game = DinoGame();
}
@override
Widget build(BuildContext context) {
return GameWidget(game: game);
}
}
flame은 기본적으로 GameWidget
의 game 프로퍼티를 통해서 FlameGame
클래스를 등록해주어야 합니다. 우리는 이전에 dino_game.dart
에서 DinoGame
클래스가 FlameGame
을 상속 받도록 했습니다. 이제 initState 메소드를 통해서 game 변수에 DinoGame
클래스를 초기화 해줍니다. 이 변수를 game 프로퍼티에 등록해줍니다.
아무것도 뜨지 않고 검은화면이 나오면 첫 단계를 완료했습니다.
Background
가장 구현하기 쉬울 거 같으면서도 화면에 따라 조정하기 힘든 배경을 먼저 해보겠습니다. 서론에서 이미 언급한 바 있지만, 우리는 앱을 웹에서 사용할 지 윈도우에서 사용할 지, 모바일에서 사용할 지 정하지 않았습니다. 사실 약간 핑계스러운 이유가 가미된 거지만 배경을 맞추는 데 정해놓은 기준이 없으면 반응형으로 만들기 까다롭기 때문에 켜지면 나오는 그 배경을 사용하는 기준으로 만들겠습니다. (배경에 대해서 여러 시도를 하긴했습니다 ㅠㅠ)
우리는 배경을 움직일 겁니다. 제가 고등학생때 즐겨하던 모바일 게임 중 하나로 '표창 키우기'가 있는데요. 이 게임도 보면 알겠지만 캐릭터가 앞으로 가는 것을 묘사하기 위해서 배경을 뒤로 움직입니다. 그래서 배경도 뒤로 움직이는 부분을 구현하기 위해서ParallaxComponent
라는 컴포넌트를 사용하겠습니다. Parallax를 번역하면 "시차" 입니다. 이 컴포넌트는 여러 레이어가 있을 때 각기 다른 속도감을 표현하기 위해 사용된다고 합니다. 자세한 내용은 Flame Components 공식 문서를 참고하면 좋을 것 같네요.
// filename: game/dino_game.dart
import 'package:flame/components.dart';
import 'package:flame/parallax.dart';
@override
Future<void> onLoad() async {
await images.loadAll(['dino.png', 'cactus.png', 'background.png']);
final parallaxComponent = await ParallaxComponent.load(
[ParallaxImageData('background.png')],
size: size,
baseVelocity: Vector2(20, 0),
);
add(parallaxComponent);
}
onLoad
메소드를 비동기 메소드로 변환해줍니다. 그리고 이후에 사용될 에셋도 미리 images.loadAll
를 통해서 등록해주면 됩니다. 이제 배경을 나타내기 위해 ParallaxComponent.load
를 통해 정의할 컴포넌트를 가져옵니다. 여기서 ParallaxImageData
를 통해 parallax에 사용될 이미지를 리스트에 넣어줍니다.
size
는 앱의 크기에 맞게 가져옵니다. 그래서 이후에 실행한 상태에서 화면을 줄였다가 펼치면 검은색 나머지 화면을 볼 수 있습니다. 불편하겠지만 제가 좋은 솔루션을 찾지를 못해서 이 방식으로 사용하겠습니다.
baseVelocity
는 컴포넌트의 기본 속도입니다. 컴포넌트가 늘어나고 캐릭터의 속도에 따라 배경의 속도를 조정할 수도 있는 프로퍼티가 있기 때문에 기본 속도라는 프로퍼티가 따로 설정되어 있는 것입니다. 그것까지는 가지 않으니 넘어 가도록 합니다.
이제 flame 의 add
메소드를 통해서 컴포넌트 요소를 등록해줍니다.
움직이는 배경을 보실수 있습니다. 사진의 엣지 부분이 갈라지는 게 표시되지만 넘어가도록 하겠습니다. ㅎㅎ..
Dino components
배경을 그렸으니 주요 인물인 공룡 컴포넌트를 그려보겠습니다. 우선 컴포넌트 파일에 다음과 같은 코드를 작성합니다.
// filename: components/dino.dart
import 'package:flame/components.dart';
import 'package:flame/collisions.dart';
class Dino extends SpriteComponent {
Dino()
: super(
size: Vector2(50, 50),
position: Vector2(100, 400),
);
@override
Future<void> onLoad() async {
sprite = await Sprite.load('dino.png');
}
}
SpriteComponent
를 상속 받아 공룡을 그려줄 것입니다. 생성 자를 통해 공룡의 크기와 초기 위치를 초기화해줍니다. 여기서 position은 앱의 왼쪽 위부터 아래 오른쪽 방향으로 값을 증가시키는 점 참고바랍니다. 이 부분도 onLoad
메소드를 통해 공룡을 그려냅니다. Sprite.add
로 에셋을 불러옵니다.
// filename: game/dino_game.dart
import '../components/dino.dart';
class DinoGame extends FlameGame {
late Dino _dino;
Future<void> onLoad() async {
(... CODE ...)
_dino = Dino();
add(_dino);
}
flame 에 공룡 컴포넌트를 추가해줍니다.
귀여운 공룡 한 마리가 게임 속에 등장했습니다~
Jumping 공룡
점프를 하는 메소드를 구현해보겠습니다.
// filename: components/dino.dart
double gravity = 800; // 중력 가속도
double jumpSpeed = -400; // 점프 속도
double verticalSpeed = 0; // 점프 또는 내려올 때의 속도
bool isJumping = false;
verticalSpeed는 현재 점프를 하든 내려오고 있는 현재에 대한 수직 스피드입니다. 기존에는 아무런 동작을 하고 있지 않기 때문에 0입니다.
// filename: components/dino.dart
void jump() {
if (!isJumping) {
isJumping = true;
verticalSpeed = jumpSpeed;
}
}
점프 메소드가 호출 되면 jump 상태를 true로 바꾸어 주고 현재 수직 속도를 jumpSpeed로 만들어줍니다. 그러면 -400이었기 때문에 - 뱡향인 위쪽으로 가게 될 것입니다.
@override
void update(double dt) {
super.update(dt);
position.y += verticalSpeed * dt;
verticalSpeed += gravity * dt;
// 땅에 닿으면 점프 중지
if (position.y >= 400) {
position.y = 400;
isJumping = false;
verticalSpeed = 0;
}
}
현재 점프가 호출된 상태여서 수직 속도는 - 방향인 위쪽으로 가고 있습니다. 이 상태에서 update
메소드를 오버라이드하여 변경해줍니다. update
메소드는 flame의 매 프레임마다 화면을 업데이트 해주는 메소드입니다. 여기서 dt(delta time) 매 프레임 간격 시간을 나타냅니다.
이제 postion.y
에 (수직 속도 * dt) 를 더해줍니다. 현재 수직 속도는 - 방향인 위쪽이기 때문에 처음에는 위쪽으로 갑니다. 중력 가속도는 + 방향인 아래쪽이었죠? 수직 속도가 현재 음수기 때문에 언젠가 0이 되는 시점이 오게됩니다. 왜냐하면 verticalSpeed += gravity * dt;
때문이죠. 이제 예를 들어 position.y 가 300 정도 됐을 때 수직속도가 0이 되었다고 합시다. 중력 가속도는 여전히 양수를 더해주고 있기 때문에 300 에서 다시 301, 302, ... position.y 가 올라가기 시작합니다. 숫자가 올라간다는 것은 화면에서는 아래로 내려가는 것을 의미합니다.
원래 위치였던 400으로 왔습니다. 이제 약간을 오차를 줄이기 위해 position.y를 400으로 만들어주고 jump 상태를 false로 만들어줍니다. 그리고 수직 속도는 다시 0이 됩니다.
Key Event 입력 받기
점프 메소드를 구현해 주었습니다. 일반적으로 모바일에서 하면 GestureDetector
와 같은 메소드로 쉽게 구현해도 됩니다. 하지만 웹이나 윈도우 등에서 실행하려면 키보드가 훨씬 편합니다. 따라서 저는 스페이스바를 눌렀을 때 점프를 하도록 하는 이벤트를 등록해보도록 하겠습니다.
키보드는 입력 받는 이벤트를 등록하려면 제가 작성한 포스트를 보는 것도 추천드립니다. 이미 작성한 포스트가 있으므로 간략하게 설명만 하고 넘어가겠습니다.
https://codcost.tistory.com/214
[Flutter] keyboard 입력받기 Web, Window, ... (Level 1 : 구현)
서론제가 테트리스를 즐겨해서 시간을 내가지고 Flutter의 Flame을 연습할겸, 테트리스를 직접 구현하면 어떨까~~ㅎㅎ하는 마음에 Flame으로 간단하게 틀만 만들고 있었는데요. 가장 기본이 되는 키
codcost.tistory.com
// filename: pages/game_page.dart
// class _DinoGamePageState extends State<DinoGamePage>
final FocusNode _focusNode = FocusNode();
스페이스 바를 입력받기 전에 앱이 포커싱 된 상태인지를 알아야 합니다. FocusNode
를 통해서 인스턴스를 생성해주고 포커싱을 추적해보겠습니다.
// filename: pages/game_page.dart
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
소멸자로 해제하는 거 잊지 마시기 바랍니다.
// filename: pages/game_page.dart
@override
Widget build(BuildContext context) {
return Focus(
autofocus: true,
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
child: GestureDetector(
onTap: () {
FocusScope.of(context).requestFocus(_focusNode);
},
child: GameWidget(game: game),
),
);
}
Focus
위젯으로 포커싱을 관리할 수있습니다. GestureDetector
위젯으로 터치 혹은 클릭이 되면 포커싱을 요청합니다. focusNode
로 포커싱을 감지할 수 있습니다. 이제 onKeyEvent
프로퍼티로 핸들을 관리하는 메소드를 구현해야합니다.
// filename: pages/game_page.dart
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
setState(() {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.space) {
game.dino.jump();
}
});
return KeyEventResult.handled;
}
onKeyEvent
프로퍼티는 KeyEventResult
의 enum 값들 중 하나로 나타내야합니다. handled
값을 통해 동작이 되도록합니다. 만약 키를 눌렀고 키가 스페이스 바라면 game.dino.jump
메소드를 호출합니다. 중간에 dino가 등장했는데요. DinoGame
클래스에서 생성한 인스턴스인 dino로 메소드를 호출해야 하기 때문입니다. 이제 마지막으로 다음 코드를 작성해줍니다.
// filename: game/dino_game.dart
Dino get dino => _dino;
getter 를 사용하여 dino에 접근할 수 있습니다. 이제 스페이스를 누르면 공룡이 점프를 하는 모습을 볼 수 있습니다.
Coming cactus
우리는 이제 장애물로 선인장을 그려줄 것입니다. 구글 공룡게임을 보면 장애물이 공룡쪽으로 다가오는 것처럼 애니메이션 되는 것이 상기되시나요? 랜덤한 위치에 여러 개의 장애물 나옵니다. 그러면 이 선인장을 만들어내는 "관리자"도 필요하겠죠? 하지만 우선 선인장 하나를 공룡쪽으로 오도록 만들어 보겠습니다.
// filename: components/obstacle.dart
import 'package:flame/components.dart';
import 'package:flame/game.dart';
class Obstacle extends SpriteComponent {
Obstacle(Vector2 position)
: super(
size: Vector2(50, 50),
position: position,
);
@override
Future<void> onLoad() async {
sprite = await Sprite.load('cactus.png');
}
@override
void update(double dt) {
super.update(dt);
position.x -= 200 * dt;
if (position.x < -size.x) {
removeFromParent();
}
}
}
이전에 dino를 만들어 내는 코드랑 비슷하죠? 여기서 position을 파라미터를 통해 전달 받는 것으로 나타냈는데요. 장애물 매니저를 통해서 장애물을 생성할 것이기 때문입니다. 그리고 매 프레임마다 update
메소드를 통해서 선인장의 x축을 공룡쪽으로 오게 만들어줍니다. 그리고 화면의 x축보다 작아지면 removeFromParent
메소드를 호출하여 그 개체를 부모 컴포넌트로 부터 자식 목록에서 제거됩니다. 그러면 더 이상 게임 루프에서 업데이트되지 않기 때문에 더이상 호출되지 않습니다.
// filename: components/obstacle_manager.dart
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import '../game/dino_game.dart';
import 'obstacle.dart';
class ObstacleManager extends Component with HasGameRef<DinoGame> {
int limit = 0;
@override
void update(double dt) {
super.update(dt);
final position = Vector2(1000, 400);
final obstacle = Obstacle(position);
if (limit < 1) {
gameRef.add(obstacle);
limit++;
}
}
void reset() {}
}
우선 선인장을 하나만 생성해보도록 하겠습니다. 여기서 update
메소드를 통해서 생성되므로 limit 변수를 통해서 개수를 제한해줍니다. 그렇지 않으면 매 프레임마다 선인장이 만들어지기 때문에 원하는 로직을 구현할 수 없습니다.
HasGameRef<DinoGame>
믹스인이 있습니다. 여기서 Obstacle
클래스를 DinoGame
클래스에서 컴포넌트를 추가하는 행위를 하지 않기 때문에 참조를 통해서 컴포넌트를 추가할 수 있도록 도와줍니다.
// filename: game/dino_game.dart
import '../components/obstacle_manager.dart;
// class DinoGame extends FlameGame
late ObstacleManager _obstacleManager;
Future<void> onLoad() async {
_obstacleManager = ObstacleManager();
add(_obstacleManager);
}
이전과 마찬가지로 DinoGame
클래스에서 ObstacleManager
컴포넌트를 추가해줍니다.
선인장 하나가 공룡쪽으로 오는 애니메이션을 구현했습니다!
Managing Cactus
선인장 하나로는 안됩니다. 이제 여러 개를 소환해볼까요? 선인장 각각의 개체에는 간격이 필요합니다. 하지만 일정한 간격이면 재미가 없겠죠. 일정한 간격에서 랜덤한 간격을 추가해줄 것입니다.
// filename: components/obstacle_manager.dart
import 'dart:math';
// class ObstacleManager extends Component with HasGameRef<DinoGame>
final List<Obstacle> obstacles = [];
final Random random = Random();
double nextObstacleDistance = 0;
이전 limit
변수와 update
메소드에 구현해놨던 선인장 1개 소환 로직은 없애줍니다.
Obstacle을 리스트에 저장해주기 위해서 리스트를 선언합니다. 각 소환된 선인장의 간격을 랜덤하게 주어야하기 때문에 dart:math
패키지를 임포트 해주고 nextObstacleDistance
변수를 통해서 각 사이의 간격을 저장해줍니다.
// filename: components/obstacle_manager.dart
@override
void update(double dt) {
super.update(dt);
if (obstacles.isEmpty ||
(gameRef.size.x - obstacles.last.position.x) > nextObstacleDistance) {
}
}
이제 update
메소드를 통해서 선인장을 생성할 것 입니다. gameRef.size.x
는 앱의 가로 길이입니다. 즉 오른쪽 끝 부분이 됩니다. 만약 obstacles 리스트에서 마지막 선인장 개체의 x 값과 오른쪽 끝부분의 간격이 nextObstacleDistance 보다 커지면 선인장을 생성합니다.
// filename: components/obstacle_manager.dart
@override
void update(double dt) {
super.update(dt);
if (obstacles.isEmpty ||
(gameRef.size.x - obstacles.last.position.x) > nextObstacleDistance) {
final position = Vector2(gameRef.size.x , 400);
final obstacle = Obstacle(position);
obstacles.add(obstacle);
gameRef.add(obstacle);
nextObstacleDistance = random.nextDouble()* 200 +300;
}
obstacles.removeWhere((obstacle) => obstacle.parent == null);
}
이전과 같이 gameRef.add(obstacle);
을 통해 컴포넌트 요소를 추가해주었는데 여기에 더해서 obstacles 의 리스트에도 추가해줍니다. nextObstacleDistance 변수는 매 생성마다 300에서 500의 값으로 랜덤하게 만들어줍니다.
화면 밖으로 나간 개체는 부모를 제거해주었기 때문에 obstacles에서도 제거주어야 합니다. removeWhere
메소드를 통해서 간단하게 구현합니다.
자세히 보이진 않지만 미세하게 랜덤한 간격으로 선인장이 소환됩니다!
Collision
공룡과 선인장의 모션 구현을 모두 마쳤습니다. 이제 대망의 게임오버 로직을 구현해봐야겠죠?
우선 각 스프라이트에 히트박스를 그리고 직접 눈으로 확인해보겠습니다.
// filename: components/dino.dart
import 'package:flame/collisions.dart';
// class Dino extends SpriteComponent with CollisionCallbacks
@override
Future<void> onLoad() async {
sprite = await Sprite.load('dino.png');
add(RectangleHitbox(
size: Vector2(30, 30),
position: Vector2(10, 10),
));
debugMode = true;
}
// filename: components/obstacle.dart
import 'package:flame/collisions.dart';
// class Obstacle extends SpriteComponent with CollisionCallbacks
@override
Future<void> onLoad() async {
sprite = await Sprite.load('cactus.png');
add(RectangleHitbox(
size: Vector2(30, 30),
position: Vector2(10, 10),
));
debugMode = true;
}
dino.dart
, obstacle.dart
파일에서 flame/collisions.dart
패키지를 임포트 해주시고 클래스에 CollisionCallbacks
을 믹스인 해줍니다. 그리고 RectangleHitbox
요소를 컴포넌트에 추가해줍니다.
RectangleHitbox
는 충돌 감지를 위한 히트 박스 메소드입니다. 이 메소드를 통해서 각 히트박스끼리 충돌을 감지할 수 있습니다.
debugMode = true;
는 필수 요소는 아닙니다. 하지만 히트박스 확인을 위해서 임시적으로 적용합니다.
히트박스를 확인할 수 있습니다! 분홍색은 스프라이트 크기, 노란색이 히트박스입니다.
이제 서로 부딪혔는 지 알기 위한 메소드를 구현해야 합니다. 공룡이 선인장에 닿았을 때 게임이 종료되어야 하겠죠. 따라서 Dino
클래스에 메소드를 구현하겠습니다.
// filename: components/dino.dart
import '../game/dino_game.dart';
// class Dino extends SpriteComponent with CollisionCallbacks, HasGameRef<DinoGame>
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
if (other is Obstacle) {
gameRef.pauseEngine();
}
super.onCollision(intersectionPoints, other);
}
// filename: game/dino_game.dart
// class DinoGame extends FlameGame with HasCollisionDetection
Dino
에 HasGameRef<DinoGame>
믹스인을 적용합니다. 그리고 onCollision
를 상속받아서 메소드를 구현해줍니다. 여기서 intersectionPoints 과 other 의 관계는 공룡과 선인장의 관계로만 생각해둡시다. other은 선인장 외에 다른 히트박스여도 되겠지만 여기서는 두 개 밖에 없어서 선인장으로만 취급합니다. 그리고 other 이 Obstacle(선인장) 이면 gameRef.pauseEngine()
메소드를 통해서 flame 엔진을 멈춰줍니다. gameRef
은 DinoGame
클래스를 참조합니다.
상호간의 충돌을 DinoGame
에서 감지할 수 있도록 HasCollisionDetection
믹스인을 적용시켜주어야 합니다.
선인장과 부딪히면 게임이 멈추게 됩니다. 이제 끝이 보이네요!
GameOver Dialog
다이얼로그 위젯은 디자인외에 flame을 공부하기에는 크게 따라해볼 작업량이 없기 때문에 별도 설명없이 코드만 제공하겠습니다. 하지만 다이얼로그를 나타내기 위해 작업해야할 로직은 따라해볼 것 입니다.
// filename: widgets/game_over_dialog.dart
import 'package:flutter/material.dart';
class GameOverDialog extends StatelessWidget {
final VoidCallback onReset;
const GameOverDialog({Key? key, required this.onReset}) : super(key: key);
@override
Widget build(BuildContext context) {
return SimpleDialog(
backgroundColor: Colors.grey.shade800,
contentPadding: const EdgeInsets.fromLTRB(0.0, 8.0, 0.0, 32.0),
titlePadding: const EdgeInsets.all(24.0),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Game Over',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white),
),
Align(
alignment: Alignment.topRight,
child: InkWell(
borderRadius: BorderRadius.circular(4),
onTap: () => Navigator.pop(context),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
Icons.close,
size: 16,
color: Colors.white,
),
),
),
),
],
),
children: [
const Padding(
padding: EdgeInsets.fromLTRB(30.0, 0.0, 30.0, 40.0),
child: Text(
'Do you want to restart the game?',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: OutlinedButton(
onPressed: onReset,
style: OutlinedButton.styleFrom(
overlayColor: Colors.black,
side: BorderSide(color: Color.fromARGB(255, 192, 255, 75)),
),
child: const Text(
'Restart',
style: TextStyle(
color: Colors.white,
),
),
),
),
],
);
}
}
특이 사항으로 다이얼로그에 콜백함수를 붙여줍니다. Game Over가 되고 Restart가 눌러진다면 reset을 해주는 동작을 구현해야하기 때문입니다.
final VoidCallback onReset;
const GameOverDialog({Key? key, required this.onReset}) : super(key: key);
(... CODE ...)
child: OutlinedButton(
onPressed: onReset,
),
이제 game_page.dart
에서 다이얼로그를 띄우는 메소드를 구현해보겠습니다.
// filename: pages/game_page.dart
import '../widgets/game_over_dialog.dart';
void _showGameOverDialog() {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return GameOverDialog(
onReset: () {
Navigator.of(context).pop();
game.reset();
FocusScope.of(context).requestFocus(_focusNode);
},
);
},
);
}
DinoGame
의 클래스에서 아직 reset
메소드는 구현하지 않았습니다. 이후에 구현할 것이니 그대로 넣어둡니다.
이제 onCollision
메소드에서 다이얼로그가 나타나도록 구현할 것입니다. 이를 위해서 콜백을 사용합니다.
// filename: game/dino_game.dart
late Function onGameOver;
DinoGame({required this.onGameOver});
콜백을 추가해줍니다~
// filename: pages/game_page.dart
void initState() {
super.initState();
game = DinoGame(onGameOver: _showGameOverDialog);
}
required 를 사용했기 때문에 프로퍼티로 이전에 정의헀던 _showGameOverDialog
메소드를 전달해줍니다. 이제 onCollision
메소드가 있는 Dino
클래스에서 어떻게 실행할까요? 맞습니다. 믹스인 HasGameRef<DinoGame>
을 통해서 DinoGame
클래스에 참조가 가능합니다! 이제 gameRef.onGameOver();
로 콜백함수를 실행할 수 있겠군요!
// filename: components/dino.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
if (other is Obstacle) {
gameRef.pauseEngine();
gameRef.onGameOver();
}
super.onCollision(intersectionPoints, other);
}
Restart
위의 사진과 같이 restart를 눌러도 게임이 아직 멈춰있는 상태입니다. 이제 다이얼로그를 통해 restart를 누르면 다시 시작하도록 만들어보겠습니다.
// filename: game/dino_game.dart
void reset() {
_dino.reset();
_obstacleManager.reset();
resumeEngine();
}
아직 Dino
와 ObsctacleManager
의 reset 메소드를 구현하지 않았지만 먼저 DinoGame
에서 리셋시켜줍니다. resumeEngine
메소드는 중지된 게임 루프를 다시 재개시켜줍니다.
// filename: components/dino.dart
void reset() {
position = Vector2(100, 400);
isJumping = false;
verticalSpeed = 0;
}
Dino
클래스입니다. 포지션을 움직이진 않았지만 점프 상태일 수도 있으므로 원래 위치로 돌려줍니다.
// filename: components/obstacle_manager.dart
void reset() {
for (final obstacle in obstacles) {
obstacle.removeFromParent();
}
obstacles.clear();
nextObstacleDistance = 0;
}
ObstacleManager
클래스입니다. 각 선인장의 부모를 지워주고 리스트를 비워줍니다. nextObstacleDistance는 0으로 초기화합니다.
후기
이 flame 엔진을 가볍게 배워볼려고 chatGPT한테 뜯어냈지만 우연치 않은 자랑으로 인해 이 게임에 궁금해하신 분이 1분이라도 있어서 글도 연습할겸 포스팅을 작성해보았습니다. 제가 이 flame 코드를 모른 채 작성했기 때문에 전문성이 많이 떨어진다는 점 양해바랍니다. 그리고 특히 배경은 움직이면 공룡과 선인장의 position도 변하는데 앱의 창에 fit 되도록 하는 것을 결국 못 찾아서 적절히 조절해서 사용하도록 만들었습니다. 이 부분에 시간을 쏟기에는 제 시간이 한정적이라 flame의 핵심만 알려주고자 포기한 부분입니다...
코드 만드는데 1시간, 이해하는데 2시간, 포스팅 작성하는데 7시간 ㅋㅋ
지적은 언제나 환영입니다. 즐겁게 봐주셔서 감사합니다.
소스 코드와 asset
https://github.com/Chun-Bae/DinoGame
GitHub - Chun-Bae/DinoGame: game of avoiding dino
game of avoiding dino. Contribute to Chun-Bae/DinoGame development by creating an account on GitHub.
github.com
'Software Framework > Flutter' 카테고리의 다른 글
[Flutter::Widget] Textfield Widget size 조절 방법! (0) | 2024.08.14 |
---|---|
[Flutter::Widget] TextField의 Border radius과 Background color를 fit하게 넣기 (0) | 2024.08.10 |
[Flutter::State] BloC 따라 연습하기 with kodeco (0) | 2024.08.06 |
[Flutter] keyboard 입력받기 Web, Window, ... (Level 1 : 구현) (0) | 2024.08.06 |
[Flutter] copyWith 메소드의 역할 (0) | 2024.08.06 |