우선 신규 프로젝트에 플레임을 설치하자.
$ flutter pub add flame
플레임 엔진으로 만든 게임은 플러터의 다른 위젯처럼 사용이 가능하다.
단, GameWidget()
으로 게임 인스턴스를 감싸서 렌더링 해야 한다.
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
class YourGamePage extends StatefulWidget {
const YourGamePage({super.key});
State<YourGamePage> createState() => _YourGamePageState();
}
class _YourGamePageState extends State<YourGamePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Airplane Game"),
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: GameWidget(
game: YourGame(), // 이부분에 게임 인스턴스를 넣어준다.
),
),
),
);
}
}
위의 구조만 따라가면 게임을 어느 크기로든, 어느 위치에서든 편리하게 불러낼 수 있다.
물론 직접 YourGame()
같이 구현할 필요 없이, 다른데서 주입해주거나 매번 인스턴스를 새로 생성하게 할수도 있다.
GameWidget
안에 넣는 FlameGame
클래스는 우리가 Stateless위젯
만들듯이 extends 해주면 된다.
기본적으로 @override
해서 사용할 수 있는 함수들을 많이 제공하는데, 대부분 그것들과 섞어서 쓰면 된다.
그 외에도 여러 Mixin
을 섞어서 탭 하는걸 인식하거나 충돌을 인식하거나 할 수 있으며,
이는 컴포넌트 레벨에서도 동일하게 적용된다.
우리는 컴포넌트를 구현 할 때 충돌 인식을 구현할 것이다.
import 'package:flame/game.dart';
class YourGame extends FlameGame {
// 게임 인스턴스가 생성될때 실행하는 함수, 대부분 여기에 내용을 배치한다.
Future<void> onLoad() async {
super.onLoad();
}
// 업데이트 되는 매 프레임마다 실행되는 로직
void update(double dt) async {
super.update(dt);
}
// 인스턴스가 해제될 떄 실행되는 로직
void onRemove() {
super.onRemove();
}
}
이제 컴포넌트를 만들어보자. 웹에서 처럼 게임에서 무언가를 구성하는 요소들을 모두 컴포넌트(Component)라고 부른다.
flame/components
를 상속하는 새로운 클래스를 만들어 보자.
우리는 스프라이트 이미지를 이용해서 게임을 만들 것이기 때문에 SpriteComponent
를 상속하면 된다.
그리고 onLoad() 함수에 스프라이트를 불러와준다.
import 'package:flame/components.dart';
class AirplaneGameBg extends SpriteComponent with HasGameRef {
Future<void> onLoad() async {
super.onLoad();
sprite = await gameRef.loadSprite('your_background_image.jpg');
size = Vector2(gameRef.size.x, gameRef.size.y);
}
}
HasGameRef
mixin은 플러터 위젯의 context처럼, 지금 이 컴포넌트를 자식으로 가지는, 부모 게임 인스턴스의 여러 요소들을 끌어다 쓸 수 있게 해준다.
여기서는 게임판의 size를 받아와서 스프라이트 이미지를 불러오는 우리 컴포넌트의 크기를 게임판의 크기로 맞추고 있다.
컴포넌트를 구성했다면 이제 게임에서 불러와야 한다. 게임 코드로 가서 백그라운드 컴포넌트를 불러내보자.
await add()
메서드로 컴포넌트를 불러와주면 된다. 이건 onLoad()
시에만 사용 가능한 메서드가 아니며, 언제든 불러낼 수 있다.
import 'package:flame/game.dart';
class YourGame extends FlameGame {
// 게임 인스턴스가 생성될때 실행하는 함수, 대부분 여기에 내용을 배치한다.
Future<void> onLoad() async {
final AirplaneGameBg _ = new AirplaneGameBg();
await add(_);
super.onLoad();
}
...
...
}
이제 또 SpriteComponent
를 사용해서 우리가 플레이 할 플레이어 캐릭터 비행기를 만들어보자.
기본 크기는 가로 세로 모두 60프레임으로 지정해두고, 만들어낼 위치(포지션)와 충돌 이벤트때 받아야 하는 함수만 외부에서 넣어주자.
그리고 충돌도 구현하기로 했으니, 동그랗게 히트박스도 만들어서 표시해주자.
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class PlayerPlane extends SpriteComponent with HasGameRef, CollisionCallbacks {
static const double playerSize = 60.0;
final Function hitAction;
PlayerPlane({required position, required this.hitAction})
: super(size: Vector2.all(playerSize), position: position);
late ShapeHitbox hitbox;
void onLoad() async {
super.onLoad();
sprite = await gameRef.loadSprite('player.png');
position = position;
// 빨간색 원으로 잘 보이는 히트박스 만들어주기
final Paint defaultPaint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke;
hitbox = CircleHitbox()
..paint = defaultPaint // paint를 하지 않거나 선을 투명으로 하면 보이지 않음
..renderShape = true;
add(hitbox);
}
// CollisionCallbacks Mixin 사용시 @override 가능, 충돌 발생시 메서드
void onCollision(Set<Vector2> points, PositionComponent other) {
super.onCollision(points, other);
if (other is ScreenHitbox) {
// 게임 화면과 충돌했을 때 메서드 정의
} else if (other is EnemyPlain) {
// other의 타입을 지정해서 충돌이 일어나는 컴포넌트별로 분기할 수 있다.
hitAction(); //외부로 받아온 매개변수
}
}
}
컴포넌트를 어느정도 정리했으면 이제 배경화면 위에 띄워보자.
import 'package:flame/game.dart';
class YourGame extends FlameGame {
final YourBackground _backGround = YourBackground();
late PlayerPlane _player;
// 게임 인스턴스가 생성될때 실행하는 함수, 대부분 여기에 내용을 배치한다.
Future<void> onLoad() async {
await add(_gameBg);
_player = PlayerPlane(
position: Vector2(size.x / 2 - 30, size.y - 100), hitAction: hitAction);
await add(_player);
super.onLoad();
}
...
...
}
이미지와 빨간 히트박스 구현이 된 것을 확인할 수 있다.
그리고 FlameGame
안에서 _player
로 불러냈기 때문에, PlayerPlane
클래스의 메서드도 사용할 수 있다.
움직이거나, 폭파시키거나, 형태를 변화하거나 등등 원하는 메서드를 만들어 사용하면 된다.
이제 적군 비행기 클래스를 만들어보자.
// enum으로 비행기의 상태를 관리하자, 날고있는지 아니면 충돌했는지
enum EnemyPlainState { flying, hit }
class EnemyPlain extends SpriteComponent with HasGameRef, CollisionCallbacks {
// 우리 플레이어 비행기랑 똑같이 60픽셀로
static const double enemySize = 60.0;
EnemyPlain({required position})
: super(size: Vector2.all(enemySize), position: position);
//초기 상태는 비행중으로
EnemyPlainState _state = EnemyPlainState.flying;
//getter를 써서 상태를 외부에서 변경 못하도록
EnemyPlainState get state => _state;
late ShapeHitbox hitbox;
late Sprite? _spirte;
void onLoad() async {
super.onLoad();
_spirte = await gameRef.loadSprite("적 비행기 이미지");
sprite = _spirte;
position = position;
// 플레이어 비행기랑 똑같이 빨간 선으로 히트박스 표시
final Paint defaultPaint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke;
hitbox = CircleHitbox()
..paint = defaultPaint
..renderShape = true;
add(hitbox);
}
void update(double dt) {
// y값(세로)을 변경해서 날아가도록 수정해보자
fly();
super.update(dt);
}
void fly(){
// y값을 변경하며 매 프레임마다 아래로 날아가도록 로직을 구현하자
}
void onCollision(Set<Vector2> points, PositionComponent other) {
super.onCollision(points, other);
if (other is ScreenHitbox) {
// 스크린과 충돌했을 때 로직
} else if (other is PlayerPlane) {
// 플레이어 비행기와 충돌했을 때 로직
}
}
}
아직 만들어야 할 메서드가 많지만 기본형은 이렇게 될 거 같다.
이 적군 비행기는 Player비행기처럼 처음부터 만들어두지 않고, Timer를 써서 일정 시간마다 만들어서 위에서 아래로 내려오게 하자.
이제 게임 코드에서 EnemyPlane을 만들어내는 로직 부분을 수정하자.
우선 제일 중요한 내 캐릭터부터 컨트롤해보자
YourGame 클래스 안에 두 메서드를 추가한다.
void flyLeft() {
_player.position = Vector2(_player.position.x - 10, _player.position.y);
}
void flyRight() {
_player.position = Vector2(_player.position.x + 10, _player.position.y);
}
x좌표는 왼 쪽 끝이 0이다. flyLeft
는 왼 쪽으로 10픽셀, flyRight
는 오른쪽으로 10 픽셀 움직여준다.
그리고 이러다가 화면 밖으로 나가버리면 안되니, 벽이랑 부딛히면 더 가지 말고 멈추라는 함수를 추가하자.
void onCollision(Set<Vector2> points, PositionComponent other) {
super.onCollision(points, other);
// 스크린과 부딛히면?
if (other is ScreenHitbox) {
// 내 비행기의 위치가 게임판의 x값보다 크면?
// 그대로 x값 0에 고정
if (position.x < size.x) {
position = Vector2(0, position.y);?
// 그게 아니면? 겹치지 않는 맨 끝 위치에 고정
} else {
position = Vector2(game.size.x - size.x, position.y);
}
}
...생략
}
이제 이 기능들을 내가 탭해서 쓸 수 있도록, 게임 상단에서 이놈들을 불러다 쓸 버튼 두 개를 화면에 배치하자
이를 위해서는 GameWidget을 가지고 있는 부모 위젯에서 코드를 수정해줄 필요가 있다.
class YourGamePage extends StatefulWidget {
const YourGamePage({super.key});
State<YourGamePage> createState() => _YourGamePageState();
}
class _YourGamePageState extends State<YourGamePage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Airplane Game"),
),
body: Center(
child: GameWidget(
game: YourGame(),
// Map<String, Widget Function> 타입의 overlayBuilderMap을 추가한다.
// String으로 Overlay 이름을 정하고 위젯을 만들어두면 Game내에서 overlay.add 가능
overlayBuilderMap: {
// context와 game을 모두 참조할 수 있다.
// game의 클래스 타입을 제대로 명시해주지 않으면 내부메서드 사용이 어렵다.
'leftRightButton': (BuildContext context, YourGame game) {
return Align(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () {
// 왼쪽으로 이동
game.flyLeft();
},
child: Text(">")),
ElevatedButton(
onPressed: () {
// 오른쪽으로 이동
game.flyRight();
},
child: Text(">")),
],
),
),
);
},
},
),
),
);
}
}
Overlay맵을 정의했다면 이제 Game쪽 코드를 수정하자!
class YourGame extends FlameGame with TapCallbacks, HasCollisionDetection {
late PlayerPlane _player;
void hitAction() {}
Future<void> onLoad() async {
final AirplaneGameBg _ = new AirplaneGameBg();
await add(_);
_player = PlayerPlane(
position: Vector2(size.x / 2 - 30, size.y - 100), hitAction: hitAction);
await add(_player);
// overlay.add를 하면 상위에 정의해둔 String을 Key로 오버레이를 불러온다.
overlays.add("leftRightButton");
super.onLoad();
}
void flyLeft() {
_player.position = Vector2(_player.position.x - 17, _player.position.y);
}
void flyRight() {
_player.position = Vector2(_player.position.x + 17, _player.position.y);
}
}
이제 확인해보면 잘 올라왔다.
우선 적 비행기를 랜덤하게 만들어보자.
YourGame의 onLoad() 메서드에 타이머와 적군 비행기 추가 로직을 추가하자.
import 'dart:math';
import 'dart:async' as ASYNC;
class YourGame extends FlameGame with TapCallbacks, HasCollisionDetection {
late PlayerPlane _player;
late ASYNC.Timer? _enemyTimer;
late ASYNC.Timer? _enemyTimer2;
// 아직 임시
void hitAction() {}
Future<void> onLoad() async {
final AirplaneGameBg _ = new AirplaneGameBg();
await add(_);
_player = PlayerPlane(
position: Vector2(size.x / 2 - 30, size.y - 100), hitAction: hitAction);
await add(_player);
overlays.add("leftRightButton");
// 타이머 한개는 1초마다, 한개는 1.5초마다 적 비행기 생성한다.
// 타이머 시간은 임의로 수정하면 된다.
_enemyTimer =
ASYNC.Timer.periodic(const Duration(milliseconds: 1000), (timer) {
add(addEnemy());
});
_enemyTimer2 =
ASYNC.Timer.periodic(const Duration(milliseconds: 1500), (timer) {
add(addEnemy());
});
super.onLoad();
}
EnemyPlain addEnemy() {
// x축의 위치를 랜덤하게 만들어준다.
// y축의 위치는 임의로 30으로 고정한다.
int randomDx = Random().nextInt(13) + 1;
return EnemyPlain(position: Vector2(randomDx * 30, 30));
}
void flyLeft() {
_player.position = Vector2(_player.position.x - 17, _player.position.y);
}
void flyRight() {
_player.position = Vector2(_player.position.x + 17, _player.position.y);
}
}
자 추가가 잘 되고 있다.
이제 EnemyPlain 클래스를 수정할 차례다.
5가지의 기능을 추가해야 한다.
1. 생성될 때 랜덤한 스프라이트 보여주기
2. 알아서 아래쪽으로 내려가게 만들기(속도는 랜덤하게)
3. PlayerPlane과 부딛히면 깜빡이며 사라지게 만들기
4. 바닥과 충돌하면 사라지게 만들기
5. 생성되었을 때 x축 맨 끝과 겹쳐지게 만들어지면 안쪽으로 위치 옮기기
꽤 많아 보이지만 코드로 보면 간단할거다!
import 'dart:math';
import 'dart:async' as ASYNC;
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
enum EnemyPlainState { flying, hit }
class EnemyPlain extends SpriteComponent with HasGameRef, CollisionCallbacks {
static const double enemySize = 60.0;
final int speed;
EnemyPlain({required position, required this.speed})
: super(size: Vector2.all(enemySize), position: position);
late ShapeHitbox hitbox;
EnemyPlainState _state = EnemyPlainState.flying;
EnemyPlainState get state => _state;
late String planeType;
late Sprite? _spirte;
// 실행시 랜덤하게 Sprite를 결정해주자!
String initRandomType() {
int type = Random().nextInt(4);
switch (type) {
case 0:
return 'airplane_game/enemies/enemy1-1.png';
case 1:
return 'airplane_game/enemies/enemy2-1.png';
case 2:
return 'airplane_game/enemies/enemy3-1.png';
case 3:
return 'airplane_game/choppers/chopper2-2.png';
default:
return 'airplane_game/enemies/enemy3-1.png';
}
}
void onLoad() async {
super.onLoad();
// 실행시 랜덤하게 Sprite를 결정해주자!
planeType = initRandomType();
// 랜덤하게 결정한 스프라이트를 로드한 놈을 할당해주기
_spirte = await gameRef.loadSprite(planeType);
// 할당한 스프라이트를 컴포넌트에 넘겨주기
sprite = _spirte;
position = position;
final Paint defaultPaint = Paint()
..color = Colors.red
..style = PaintingStyle.stroke;
hitbox = CircleHitbox()
..paint = defaultPaint
..renderShape = true;
add(hitbox);
}
void update(double dt) {
// 외부에서 속도를 받아와서, flying(날고있는)상태면 계속 y값을 아래로 내리기
if (_state == EnemyPlainState.flying) {
position = Vector2(position.x, position.y + speed);
}
super.update(dt);
}
void onCollision(Set<Vector2> points, PositionComponent other) {
super.onCollision(points, other);
if (other is ScreenHitbox) {
// PlayerPlane때 한대로 비슷하게 구현하면 된다.
if (position.x > game.size.x) {
if (position.x < size.x) {
position = Vector2(0, position.y);
return;
} else {
position = Vector2(game.size.x - size.x, position.y);
return;
}
}
// removeFromParent는 컴포넌트 클래스의 내부함수로, 이 컴포넌트를 해제한다.
// 나랑 안 부딛히고 바닥에 부딛히면 없애버리자.
if (position.y + size.y > game.size.y) {
removeFromParent();
}
// player랑 부딛히면 비행기를 멈추고 사라지게 하는 함수 추가
} else if (other is PlayerPlane) {
stopPlane();
}
}
ASYNC.Timer? destroyTimer;
void stopPlane() {
_state = EnemyPlainState.hit;
destroy();
Future.delayed(const Duration(seconds: 1), (() => removeFromParent()));
// Timer클래스는 다 사용하면 항상 cancel()로 해제하자
destroyTimer?.cancel();
}
// PlayerPlane과 충돌나면 깜빡거리는 점멸효과를 1초정도 준다.
void destroy() async {
_spirte = await gameRef.loadSprite(planeType);
bool blink = false;
sprite = null;
destroyTimer =
ASYNC.Timer.periodic(const Duration(milliseconds: 50), (timer) {
if (blink == true) {
sprite = null;
blink = false;
} else {
sprite = _spirte;
blink = true;
}
});
}
}
이제 잘 굴러가나 체크해보자
랜덤하게 비행기 이미지들도 잘 불러와지고, 아래로 내려오되 속도도 랜덤하다.
그리고 충돌하면 1초간 깜빡거리며 이후에 잘 사라지고 있다.
여기까지 왔다면 Flutter Flame 엔진의 기초적인 부분은 한번씩 다 사용해봤다고 생각한다.
이제 직접 게임을 만들며 게임 안에 게임진행상태, HP, SCORE등 내부 상태를 주고 관리하며 게임을 고도화 해보면 좋을 것 같다.