최종 결과 스케치
로직
- 그리드 기반 배경: 뱀의 방향을 잡는 데 도움이 될 수 있지만 항상 필요한 것은 아닙니다.
- 성장하는 뱀: 그리드 위로 기어가는 연결된 사각형 떼. 뱀이 먹이를 먹으면 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));
}
}
게임 클래스(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!);
}
}
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;
}
}
뱀(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
'프로그램 개발해서 돈벌기 > flutter' 카테고리의 다른 글
flutter 오픈 소스 추천 (0) | 2023.03.29 |
---|---|
2023년 최고의 Flutter 인터뷰 질문 및 답변 32개 (0) | 2023.03.29 |
Targeting S+ (version 31 and above) requires that an explicit value for android:exported be defined when intent filters are present] (0) | 2023.03.28 |
flutter에서 Row와 Column 내 텍스트 왼쪽 정렬 및 여백 (0) | 2023.03.23 |
flutter에서 MaterialApp 배경색 변경 (0) | 2023.03.23 |
댓글