티스토리 뷰

Flutter 기반의 Flame 게임엔진이 1.0.0 버전으로 release 되었다.

(https://flame-engine.org/)

 

안 해볼수 없지.

 

무엇을 만들어볼까 고민해보다.. 어릴적에 재밋게 했던 닷지 게임을 만들어 보기로 했다.

멀티플랫폼을 지향하는 flutter 이지만, 이번 프로젝트는 오직 웹에서만 사용할 생각이다.

 

이유는 크게 두 가지 인데,

  1. 모바일 화면보다는 약간 넓고 고정된 크기의 스크린이 필요함
  2. Github Page를 이용한 publish가 가능함!!!! (결정적)

 

시작하기에 앞서 결과물은 아래 링크에서 확인할 수 있다.

게임플레이 : https://vip00112.github.io/infinity_alive/

소스코드 : https://github.com/vip00112/infinity_alive

 

 

그럼, 시작!

 

코드 작성에 앞서 큰 가지의 기획이 필요하다.

  1. 스크린의 상하좌우에서 랜덤하게 미사일이 생성되고, 반대편의 랜덤한 위치로 이동
  2. 낮은 확률로 일반 속도보다 빠른 속도의 미사일이 생성
  3. 비행기는 최초 마우스 오버 후 트래킹
  4. 게임은 일시정지, 게임중, 게임오버 세 가지 상태가 있음
  5. 생존시간이 늘어감에 따라 미사일의 개수와 스피드가 증가

 

찬찬히 한줄 씩 봐도 이해하는데 무리가 없을 만큼 직관적이고, 간단하기 때문에 코드에 대한 설명은 따로 안 함.

 

그럼, 제일 먼저 pubspec.yaml 파일에 flame을 추가 해준다.

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2

  # Flame
  flame: ^1.0.0

 

게임 설정을 저장하기 위한 global.dart 파일을 추가

enum GameStatus { pause, run, gameover }

class Global {
  static double deviceWidth = 0;
  static double deviceHeight = 0;

  static GameStatus status = GameStatus.gameover;
  static int level = 1;
  static double gameSpeed = 100;
  static double score = 0;

  static bool isPause() => status == GameStatus.pause;
  static bool isRun() => status == GameStatus.run;
  static bool isOver() => status == GameStatus.gameover;
}

 

기본으로 생성되어 있는 main.dart 파일의 내용을 싹 지우고 아래 코드로 대체

- 스크린 사이즈는 800x600으로 고정

- UI와 게임 화면을 분리하기 위해 Stack 위젯을 사용

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

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  final manager = GameManager();
  final menuOverlay = MenuOverlay(game: manager);
  manager.menu = menuOverlay;
  runApp(
    MaterialApp(
      home: Scaffold(
        body: SafeArea(
          child: Container(
            alignment: Alignment.center,
            child: SizedBox(
              width: 800,
              height: 600,
              child: Stack(
                fit: StackFit.expand,
                children: [
                  GameWidget(game: manager),
                  menuOverlay,
                ],
              ),
            ),
          ),
        ),
      ),
    ),
  );
}

 

실제 게임의 메인 Loop를 담당하는 game_manager.dart 파일 추가

- Flame 게임엔진 사용을 위한 FlameGame 상속

- 비행기의 마우스 트래킹과 미사일 충돌감지를 위한 HasCollidables, MouseMovementDetector 상속

- update 함수는 매 프레임마다 실행되며, 인자로 오는 dt는 delta time을 의미함

import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:infinity_alive/global.dart';
import 'package:infinity_alive/menu_overlay.dart';
import 'package:infinity_alive/missile.dart';
import 'package:infinity_alive/spaceship.dart';

class GameManager extends FlameGame with HasCollidables, MouseMovementDetector {
  late SpaceShip spaceShip;
  late MenuOverlay menu;
  final List<Missile> missiles = [];

  int createCount = 50;

  @override
  Future<void>? onLoad() async {
    Global.deviceWidth = size[0];
    Global.deviceHeight = size[1];

    spaceShip = SpaceShip();
    add(spaceShip);

    return super.onLoad();
  }

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

    if (Global.isPause()) return;
    if (Global.isOver()) {
      menu.refreshScreen();
      return;
    }

    if (createCount > 0) {
      var missile = Missile();
      add(missile);
      missiles.add(missile);
      createCount--;
    }

    Global.score += dt;

    if (Global.score >= Global.level * 10 && Global.level <= 20) {
      Global.level++;
      Global.gameSpeed += 10;
      createCount += 5;
    }
    menu.refreshScreen();
  }

  @override
  void onMouseMove(PointerHoverInfo info) {
    if (Global.isPause() || Global.isOver()) return;

    spaceShip.move(info.eventPosition.game);
  }

  void restart() {
    for (var missile in missiles) {
      remove(missile);
    }
    missiles.clear();
    createCount = 50;
    spaceShip.restart();
    Global.level = 1;
    Global.gameSpeed = 100;
    Global.score = 0;
    Global.status = GameStatus.run;
  }
}

 

UI를 담당할 menu_overlay.dart 파일 추가

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:infinity_alive/game_manager.dart';
import 'package:infinity_alive/global.dart';

class MenuOverlay extends StatefulWidget {
  MenuOverlay({Key? key, required this.game}) : super(key: key);

  final GameManager game;
  final state = _MenuOverlayState();

  @override
  State<MenuOverlay> createState() => state;

  void refreshScreen() {
    state.refreshScreen();
  }
}

class _MenuOverlayState extends State<MenuOverlay> {
  final String resumeText = "RESUME";
  final String startText = "START";
  final String pauseText = "PAUSE";
  final String overText = "GAME OVER";

  @override
  Widget build(BuildContext context) {
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);

    return Column(
      children: [
        const SizedBox(height: 10),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: gameStatusButtonClick,
              child: Global.isRun()
                  ? Text(pauseText)
                  : Global.isPause()
                      ? Text(resumeText)
                      : Text(startText),
            ),
          ],
        ),
        (Global.isPause() || Global.isOver()) ? const SizedBox(height: 80) : const SizedBox.shrink(),
        Column(
          children: [
            (Global.isPause() || Global.isOver())
                ? Column(
                    children: [
                      Global.isPause()
                          ? Text(
                              pauseText,
                              style: const TextStyle(color: Colors.white, fontSize: 80),
                            )
                          : Text(
                              overText,
                              style: const TextStyle(color: Colors.red, fontSize: 80),
                            ),
                      const SizedBox(height: 200),
                      Text(
                        "LEVEL : ${Global.level}",
                        style: const TextStyle(color: Colors.white, fontSize: 15),
                      ),
                      const SizedBox(height: 5),
                      Text(
                        "SPEED : ${Global.gameSpeed}",
                        style: const TextStyle(color: Colors.white, fontSize: 15),
                      ),
                      const SizedBox(height: 5),
                      Text(
                        "MISSILE : ${widget.game.missiles.length}",
                        style: const TextStyle(color: Colors.white, fontSize: 15),
                      ),
                      const SizedBox(height: 62),
                      Text(
                        "SCORE : ${Global.score.toStringAsFixed(4)}",
                        style: const TextStyle(color: Colors.white, fontSize: 20),
                      ),
                    ],
                  )
                : Column(
                    children: [
                      const SizedBox(height: 500),
                      Text(
                        "SCORE : ${Global.score.toStringAsFixed(4)}",
                        style: const TextStyle(color: Colors.white, fontSize: 20),
                      ),
                    ],
                  ),
          ],
        ),
      ],
    );
  }

  void gameStatusButtonClick() {
    if (Global.isRun()) {
      Global.status = GameStatus.pause;
    } else if (Global.isPause()) {
      Global.status = GameStatus.run;
    } else {
      widget.game.restart();
    }
    setState(() {});
  }

  void refreshScreen() {
    setState(() {});
  }
}

 

비행기의 Sprite와 객체를 담당할 spaceship.dart 파일 추가

- Sprite 표현을 위한 SpriteComponent 상속

- 충돌감지를 위한 HasHitboxes, Collidable 상속

- onGameResize는 스크린 리사이징 외에도 최초 실행 시 실행되므로 최초 실행시 여부를 확인하여 위치 셋팅

import 'package:flame/components.dart';
import 'package:flame/geometry.dart';
import 'package:infinity_alive/global.dart';
import 'package:infinity_alive/missile.dart';

class SpaceShip extends SpriteComponent with HasHitboxes, Collidable {
  SpaceShip() : super(size: Vector2.all(32));

  bool isLoadedFirst = false;
  bool isTouched = false;

  @override
  Future<void>? onLoad() async {
    sprite = await Sprite.load('ship.png');
    anchor = Anchor.center;

    final hitbox = HitboxRectangle(relation: Vector2.all(0.5));
    addHitbox(hitbox);

    return super.onLoad();
  }

  @override
  void onGameResize(Vector2 gameSize) {
    super.onGameResize(gameSize);

    if (!isLoadedFirst) {
      isLoadedFirst = true;

      position = gameSize / 2;
    }
  }

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

    if (Global.isPause() || Global.isOver()) return;
  }

  @override
  void onCollision(Set<Vector2> points, Collidable other) {
    if (other is Missile) {
      Global.status = GameStatus.gameover;
    }
  }

  void move(Vector2 movePosition) {
    if (!isTouched) {
      isTouched = toRect().contains(movePosition.toOffset());
      return;
    }

    position = movePosition;
  }

  void restart() {
    isTouched = false;
    position.x = Global.deviceWidth / 2;
    position.y = Global.deviceHeight / 2;
  }
}

 

미사일의 Sprite와 객체를 담당할 missile.dart 파일 추가

- Sprite 표현을 위한 SpriteComponent 상속

- 충돌감지를 위한 HasHitboxes, Collidable 상속

- onGameResize는 스크린 리사이징 외에도 최초 실행 시 실행되므로 최초 실행시 여부를 확인하여 위치 셋팅

- 매 프레임마다 실행되는 update 함수에서 미사일 셋팅 값 변경

import 'dart:math';
import 'package:flame/geometry.dart';
import 'package:infinity_alive/global.dart';
import 'package:flame/components.dart';
import 'package:infinity_alive/spaceship.dart';

class Missile extends SpriteComponent with HasHitboxes, Collidable {
  Missile() : super(size: Vector2.all(4));

  late Sprite fast;
  late Sprite normal;

  bool isLoadedFirst = false;
  bool isFast = false;
  Vector2 startPosition = Vector2(0, 0);
  Vector2 endPosition = Vector2(0, 0);

  final random = Random();

  @override
  Future<void>? onLoad() async {
    fast = await Sprite.load('missile_fast.png');
    normal = await Sprite.load('missile_normal.png');

    reloadSprite();

    final hitbox = HitboxRectangle(relation: Vector2.all(1));
    addHitbox(hitbox);

    return super.onLoad();
  }

  @override
  void onGameResize(Vector2 gameSize) {
    super.onGameResize(gameSize);

    if (!isLoadedFirst) {
      isLoadedFirst = true;

      reloadPosition();
    }
  }

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

    if (Global.isPause() || Global.isOver()) return;

    if (isScreenOut(position.x, position.y)) {
      reloadSprite();
      reloadPosition();
    }

    var diff = endPosition - startPosition;
    double speed = dt * Global.gameSpeed;
    if (isFast) {
      speed *= 1.5;
    }

    var next = diff.normalized() * speed;
    position += next;
  }

  bool isScreenOut(double x, double y) {
    return x < 0 || x > Global.deviceWidth || y < 0 || y > Global.deviceHeight;
  }

  void reloadSprite() {
    int ran = random.nextInt(5);
    isFast = ran == 0;
    if (isFast) {
      sprite = fast;
    } else {
      sprite = normal;
    }
    anchor = Anchor.center;
  }

  void reloadPosition() {
    int ran = random.nextInt(4);
    double startX, startY, endX, endY;

    // 좌
    if (ran == 0) {
      startX = 0.0;
      startY = random.nextInt(Global.deviceHeight.toInt()).toDouble();
      endX = Global.deviceWidth;
      endY = random.nextInt(Global.deviceHeight.toInt()).toDouble();

      // 상
    } else if (ran == 1) {
      startX = random.nextInt(Global.deviceWidth.toInt()).toDouble();
      startY = 0.0;
      endX = random.nextInt(Global.deviceWidth.toInt()).toDouble();
      endY = Global.deviceHeight;

      // 우
    } else if (ran == 2) {
      startX = Global.deviceWidth;
      startY = random.nextInt(Global.deviceHeight.toInt()).toDouble();
      endX = 0.0;
      endY = random.nextInt(Global.deviceHeight.toInt()).toDouble();

      // 하
    } else {
      startX = random.nextInt(Global.deviceWidth.toInt()).toDouble();
      startY = Global.deviceHeight;
      endX = random.nextInt(Global.deviceWidth.toInt()).toDouble();
      endY = 0.0;
    }

    startPosition = Vector2(startX, startY);
    endPosition = Vector2(endX, endY);
    position = Vector2(startX, startY);
  }
}

 

F5를 눌러 디버깅해보니 크롬 새 창이 열리며 잘 된다.

아무리 미니게임 이라지만, 이렇게 간단한 코드로 게임 하나가 나오다니... 좋다...

 

그럼, 이제 Github Page를 이용한 Publish를 해보자.

이 부분은 사실 flame 공식 사이트에 잘 나와있어서, 따라하기만 하면 됐다.

(https://docs.flame-engine.org/1.0.0/platforms.html#deploy-your-game-to-github-pages)

 

 

먼저 Github에 해당 프로젝트를 올릴 Repository를 추가한다.

필자는 infinity_alive라는 이름으로 추가 했음.

 

프로젝트 최상위 폴더 바로 밑으로 .github\workflows 폴더를 추가한다.

그리고 그 밑으로 workflow.yml을 추가한다.

(.yaml, .yml 둘 다 상관 없음)

 

workflow.yml 파일에 아래 내용을 추가 한다.

- on/push/branches : 해당 브랜치로 코드가 push 될때 Github Action이 실행됨

- jobs/build/steps/with/baseHref : Repository 이름을 양 끝 슬래쉬(/) 사이에 넣어준다.

name: infinity_alive

on:
  push:
    branches: master

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v2
      - uses: subosito/flutter-action@v1
      - uses: bluefireteam/flutter-gh-pages@v7
        with:
          baseHref: /infinity_alive/
          webRenderer: canvaskit

 

마지막으로 master 브랜치로 push를 해주면 프로젝트 셋팅은 끝!

 

이제부턴 Github으로 가서 셋팅을 해야 한다.

아까 만든 Repository의 Action 페이지로 이동한다.

아마 우측 workflow runs 목록에 완료 아이콘이 아닌 progress 아이콘이 보일 것이다.

완료가 될 때까지 기다려준다.

 

전부 완료처리가 되면 상단에 Settings > Pages 페이지로 이동한다.

아래와 같이 우측 Source 부분에 브랜치를 gh-pages로 설정하고 Save를 한다.

저런 브랜치를 만든적이 없는데?!?! --> workflow에서 만들어 준 브랜치임.

그리고 바로 위 녹색 박스안에 주소를 클릭하면 빌드 후 publish된 내가 만든 게임이 나온다.

 

 

짠!!!

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday