본문 바로가기
프로그램 개발해서 돈벌기/flutter

Flutter와 Flame을 사용하여 뱀(스네이크) 2D 게임 만들기

by ubmuhan 2023. 3. 29.
반응형

 

 

최종 결과 스케치

로직

  • 그리드 기반 배경: 뱀의 방향을 잡는 데 도움이 될 수 있지만 항상 필요한 것은 아닙니다.
  • 성장하는 뱀: 그리드 위로 기어가는 연결된 사각형 떼. 뱀이 먹이를 먹으면 1칸씩 늘어난다.
  • 음식: 그리드에 빨간색 원이 무작위로 나타납니다.

배경 그리기

다른 클래스에 영향을 주지 않고 게임을 미세 조정할 수 있는 별도의 구성 클래스를 사용

class GameConfig {

  // Defines the number of rows in the grid
  static const rows = 24;

  // Defines the number of columns in the grid
  static const columns = 12;

  // Cell size in px
  static const cellSize = 32;
}

 

SnakeGame은 canvasSize에 액세스 할 수 있으며 이를 사용하여 OffSets 개체를 구성합니다. 이 편리한 클래스는 그리드의 시작 및 종료 좌표를 반환합니다. 그리고 배경을 그릴 수 있습니다.

class SnakeGame extends FlameGame {
  OffSets offSets = OffSets(Vector2.zero());

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

    offSets = OffSets(canvasSize);
  }
}

class OffSets {
  Vector2 start = Vector2.zero();
  Vector2 end = Vector2.zero();

  OffSets(Vector2 canvasSize) {
    var gameAreaX = GameConfig.cellSize * GameConfig.columns;
    var gameAreaY = GameConfig.cellSize * GameConfig.rows;

    start = Vector2((canvasSize.x - gameAreaX) / 2, (canvasSize.y - gameAreaY) / 2);
    end = Vector2(canvasSize.x - start.x, canvasSize.y - start.y);
  }
}

 

배경 그리드를 그립니다.

class BackGround extends PositionComponent with HasGameRef<SnakeGame> {

  Offset start = Offset.zero;
  Offset end = Offset.zero;

  final int cellSize;

  BackGround(this.cellSize);

  @override
  Future<void> onLoad() async {
    super.onLoad();
    start = gameRef.offSets.start.toOffset();
    end = gameRef.offSets.end.toOffset();
  }

  @override
  void render(Canvas canvas) {
    canvas.drawRect(Rect.fromPoints(start, end), Styles.white);
    _drawVerticalLines(canvas);
    _drawHorizontalLines(canvas);
  }

  void _drawVerticalLines(Canvas c) {
    for (double x = start.dx; x <= end.dx; x += cellSize) {
      c.drawLine(Offset(x, start.dy), Offset(x, end.dy), Styles.blue);
    }
  }

  void _drawHorizontalLines(Canvas c) {
    for (double y = start.dy; y <= end.dy; y += cellSize) {
      c.drawLine(Offset(start.dx, y), Offset(end.dx, y), Styles.blue);
    }
  }
}

 

위 백그라운드를 SnakeGame에 적용한 코드입니다.

class SnakeGame extends FlameGame {

  OffSets offSets = OffSets(Vector2.zero());

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

    offSets = OffSets(canvasSize);

    add(BackGround(GameConfig.cellSize));
  }
}

 

그림 1. 백그라운드 결과 화면

 

게임 클래스(FlameGame)를 Flutter 위젯 사용을 우한 래핑

void main() {
  runApp(
    GameWidget(
      game: SnakeGame(),
    ),
  );
}

 

뱀(스네이크) 엔터티

뱀 렌더링은 그리드 셀(2차원 배열 데이터)을 렌더링 하는 것입니다.

class Grid {
  final int _rows;
  final int _columns;

  List<List<Cell>> _cells = List.empty(growable: true);

  List<List<Cell>> get cells => _cells;

  Grid(this._rows, this._columns, int cellSize) {
    _cells = List.generate(
        _rows,
        (row) => List.generate(
            _columns,
            (column) =>
                Cell(Vector2(row.toDouble(), column.toDouble()), cellSize),
            growable: false),
        growable: false);
  }

  Cell findCell(int column, int row) {
    try {
      return _cells[row][column];
    } on RangeError {
      return border;
    }
  }
}

 

각 셀은 상태를 가지며 상태를 기반으로 셀을 렌더링 합니다.

  • onLoad 메서드는 Cell의 위치를 ​​계산합니다.
  • render 메서드는 cellType을 확인하고 무엇을 그릴지 결정합니다.
enum CellType { empty, snakeBody }

class Cell extends PositionComponent with HasGameRef<SnakeGame> {

  static Cell zero = Cell(Vector2(0, 0), 0);

  final Vector2 _index;
  final int _cellSize;
  CellType cellType;
  Vector2 _location = Vector2.zero();

  int get row => _index.x.toInt();
  int get column => _index.y.toInt();
  Vector2 get index => _index;
  Vector2 get location => _location;

  Cell(this._index, this._cellSize, {this.cellType = CellType.empty});

  @override
  Future<void> onLoad() async {
    super.onLoad();
    var start = gameRef.offSets.start;
    _location = Vector2(
        column * _cellSize + start.x, row * _cellSize + start.y);
  }

  @override
  void render(Canvas canvas) {
    // TODO get rid of switch by making the cell type an object and directly call render on it.
    switch (cellType) {
      case CellType.snakeBody:
        SnakeBody.render(canvas, _location, _cellSize);
        break;
      case CellType.empty:
        break;
    }
  }
}

 

스네이크의 신체 부위는 단지 검은색 사각형 의 묶음입니다. 그리고 상태 유지를 위해서 Snake 클래스는 LinkList입니다.

class Snake {

  final LinkedList<SnakeBodyPart> snakeBody = LinkedList();

  Cell head = Cell.zero;

  void setHead(Cell cell) {
    head = cell;
  }

  void addCell(Cell cell) {
    _add(SnakeBodyPart.fromCell(cell));
  }

  void _add(SnakeBodyPart part) {
    snakeBody.add(part);
  }

  void _addFirst(Cell cell) {
    snakeBody.addFirst(SnakeBodyPart.fromCell(cell));
  }

  void _removeLast() {
    snakeBody.last.cell.cellType = CellType.empty;
    snakeBody.remove(snakeBody.last);
  }
}

 

게임 로직을 위한 World 클래스입니다.

Snake 객체는 World에서 초기화합니다.

class World extends Component {
  final Grid _grid;
  final Snake _snake = Snake();

  World(this._grid) {
    _initializeSnake();
  }

  void _initializeSnake() {
    var headIndex = GameConfig.headIndex;
    var snakeLength = GameConfig.initialSnakeLength;

    for (int i = 0; i < snakeLength; i++) {
      var snakePart =
          _grid.findCell(headIndex.x.toInt() - i, headIndex.y.toInt());
      _snake.addCell(snakePart);
      if (i == 0) {
        _snake.setHead(snakePart);
      }
    }
  }
}

 

SnakeGame에 cell과 world 추가

class SnakeGame extends FlameGame {
  Grid grid = Grid(GameConfig.rows, GameConfig.columns, GameConfig.cellSize);
  World? world;
  OffSets offSets = OffSets(Vector2.zero());

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

    offSets = OffSets(canvasSize);

    add(BackGround(GameConfig.cellSize));
    grid.cells.forEach((rows) => rows.forEach((cell) => add(cell)));

    world = World(grid);
    add(world!);
  }
}

 

그림 2. cell과 world가 추가된 결과 화면

 

Food

비어 있는 cell에 임의 인덱스를 생성합니다. 그리고 cellType을 food로 변경합니다.

void generateFood() {
    var allCells = _cells.expand((element) => element).toList();
    var emptyCells = allCells
        .where((element) => element.cellType == CellType.empty)
        .toList();
    emptyCells[Random().nextInt(emptyCells.length)].cellType = CellType.food;
  }

 

class Food {

  static void render(Canvas canvas, Vector2 location, int cellSize) {
    canvas.drawCircle(
        findMiddle(location, cellSize), findRadius(cellSize), Styles.red);
  }

  static Offset findMiddle(Vector2 location, int cellSize) {
    return Offset(location.x + cellSize / 2, location.y + cellSize / 2);
  }

  static double findRadius(int cellSize) {
    return cellSize / 2 - GameConfig.foodRadius;
  }
}

 

그림 3. food 추가한 결과 화면

 

뱀(Snake) 움직이기

뱀을 움직이기 전에 뱀의 방향을 알아야 합니다. 첫 번째 방향은 ' 오른쪽 '이 될 것입니다. 방향을 사용하여 다음 셀을 계산할 수 있습니다.

void move(Cell nextCell) {
    _removeLast();
    head = nextCell;
    _addFirst(head);
  }

 

뱀의 몸에서 마지막 세포를 제거하고 다음 세포를 새 머리로 만듭니다.

 

동적 FPS

이동 논리의 문제는 엔진이 초당 60 프레임으로 설정되어 있을 때 뱀이 너무 빨리 움직인다는 것입니다. 이는 초당 60개의 이동 호출과 같기 때문입니다. 뱀의 속도는 게임의 난이도 요소 중 하나이기 때문에 이를 동적으로 조정할 수 있어야 합니다. 

델타 시간을 누적한 다음 의도 한 값에 도달하면 updateDynamic 함수를 실행합니다.

World 구성 요소는  동적 fps 구성 요소를 확장하여 플레이 가능한 방식으로 게임 로직을 실행합니다.

abstract class DynamicFpsPositionComponent extends PositionComponent {

  double _fps = 60;
  double _targetDt = 1 / 60;
  double _dtTotal = 0;

  DynamicFpsPositionComponent(double fps) {
    setFps(fps);
  }

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

    if (_dtTotal >= _targetDt) {
      _dtTotal = 0;
      updateDynamic(dt);
    }
  }

  void updateDynamic(double dt);

  void setFps(double fps) {
    _fps = fps;
    _targetDt = 1 / _fps;
  }
}

 

입력 처리 문제

FPS 문제를 해결했지만 이 솔루션은 입력 관리라는 새로운 문제를 만듭니다. 

일반적으로 게임에서는 입력이 마우스 오른쪽 버튼 클릭이면 이렇게 합니다. 그러나 낮은 프레임 속도로 실행 중이기 때문에 사용자는 프레임 사이에 여러 입력을 입력할 수 있습니다.

 

이러한 상황이 발생하면 가장 일반적인 솔루션은 Queue를 사용하는 것입니다. 대기열에 입력을 추가하고 프레임이 렌더링 되면 대기열에서 첫 번째 명령을 실행합니다. 너무 많은 입력이 대기하지 않도록 하려면 대기열의 크기를 제한해야 합니다.

 

게임에서 입력은 터치입니다. 결과적으로 대기열은 화면의 터치포인트를 유지합니다.

또한 evaluateNextInput(snake) 메서드도 만들었습니다. 이 방법은 세 가지 작업을 수행합니다.

  • 첫 번째 터치포인트를 가져와 대기열에서 제거합니다.
  • 뱀의 머리에서 터치 위치까지의 변위 벡터를 계산합니다.
  • 계산된 벡터의 방향을 기준으로 뱀의 방향을 결정하고 설정합니다.
class CommandQueue {
  final List<Vector2> touches = [];

  add(Vector2 touchPoint) {
    if (touches.length != 3) {
      touches.add(touchPoint);
    }
  }

  void evaluateNextInput(Snake snake) {
    if (touches.isNotEmpty) {
      var touchPoint = touches[0];
      touches.remove(touchPoint);

      var delta = snake.displacementToHead(touchPoint);

      snake.direction = snake.isHorizontal()
          ? delta.y < 0
              ? Direction.up
              : Direction.down
          : delta.x < 0
              ? Direction.left
              : Direction.right;
    }
  }
}

 

SnakeGame 클래스와 World에 TapUpInfo 적용

class SnakeGame extends FlameGame with TapDetector {
  World? world;

  @override
  void onTapUp(TapUpInfo info) {
    world!.onTapUp(info);
  }
}
class World extends DynamicFpsPositionComponent with HasGameRef<SnakeGame> {
  ...
  final CommandQueue _commandQueue = CommandQueue();

  ...

  @override
  void updateDynamic(double dt) {
    if (!gameOver) {
      _commandQueue.evaluateNextInput(_snake);
    }
  }

  void onTapUp(TapUpInfo info) {
    final touchPoint = info.eventPosition.game;
    _commandQueue.add(touchPoint);
  }
}

 

뱀(Snake) 성장

void grow(Cell nextCell) {
    head = nextCell;
    _addFirst(head);
  }

 

주요 게임 로직

  • 먼저 게임이 끝났는지 확인하고 그렇지 않으면 대기열의 다음 입력을 평가합니다. 이것은 뱀의 방향을 설정합니다.
  • 새로운 방향에 따라 다음 셀을 계산합니다.
  • 다음 셀이 경계가 아닌 경우 뱀이 충돌했는지 확인합니다.
  • 그런 다음다음 셀의 셀 유형을 확인합니다. 음식 이면  이 자라서 그리드에 새로운 음식을 생성합니다. 그렇지 않으면  은 그냥 움직입니다.
@override
  void updateDynamic(double dt) {
    if (!gameOver) {
      _commandQueue.evaluateNextInput(_snake);

      var nextCell = _getNextCell();

      if (nextCell != Grid.border) {
        if (_snake.checkCrash(nextCell)) {
          gameOver = true;
        } else {
          if (nextCell.cellType == CellType.food) {
            _snake.grow(nextCell);
            _grid.generateFood();
          } else {
            _snake.move(nextCell);
          }
        }
      } else {
        gameOver = true;
      }
    }
  }

 

 

소스

https://github.com/erdiizgi/flutter_flame_snake

 

 

원문

https://blog.devgenius.io/lets-create-a-snake-game-using-flutter-and-flame-38482d3cf0ff

 

Let’s Create a Snake game using Flutter and Flame!

Snake is such a classic genre— one of the few memories to raise a smile.

blog.devgenius.io

 

 

 

반응형

댓글