Flutter와 Flame으로 2D 물리 게임 빌드

1. 시작하기 전에

Flame은 Flutter 기반 2D 게임 엔진입니다. 이 Codelab에서는 Forge2D라는 Box2D 라인을 따라 2D 물리 시뮬레이션을 사용하는 게임을 빌드합니다. Flame의 구성요소를 사용하여 사용자가 플레이할 수 있도록 시뮬레이션된 실제 현실을 화면에 칠합니다. 완료되면 게임이 다음 애니메이션 GIF처럼 표시됩니다.

2D 물리학 게임으로 플레이하는 게임 애니메이션

기본 요건

학습 내용

  • 다양한 유형의 물리적 몸부터 시작하여 Forge2D의 기본적인 작동 방식
  • 2D에서 물리적 시뮬레이션을 설정하는 방법

필요한 항목

선택한 개발 타겟의 컴파일러 소프트웨어입니다. 이 Codelab은 Flutter가 지원하는 6가지 플랫폼에서 모두 작동합니다. Windows를 타겟팅하려면 Visual Studio가, macOS 또는 iOS를 타겟팅하려면 Xcode, Android를 타겟팅하려면 Android 스튜디오가 필요합니다.

2. 프로젝트 만들기

Flutter 프로젝트 만들기

Flutter 프로젝트를 만드는 방법에는 여러 가지가 있습니다. 이 섹션에서는 간결성을 위해 명령줄을 사용합니다.

다음 단계를 실행하십시오.

  1. 명령줄에서 Flutter 프로젝트를 만듭니다.
$ flutter create --empty forge2d_game
Creating project forge2d_game...
Resolving dependencies in forge2d_game... (4.7s)
Got dependencies in forge2d_game.
Wrote 128 files.

All done!
You can find general documentation for Flutter at: https://docs.flutter.dev/
Detailed API documentation is available at: https://api.flutter.dev/
If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev

In order to run your empty application, type:

  $ cd forge2d_game
  $ flutter run

Your empty application code is in forge2d_game/lib/main.dart.
  1. 프로젝트의 종속 항목을 수정하여 Flame 및 Forge2D를 추가합니다.
$ cd forge2d_game
$ flutter pub add characters flame flame_forge2d flame_kenney_xml xml
Resolving dependencies... 
Downloading packages... 
  characters 1.3.0 (from transitive dependency to direct dependency)
  collection 1.18.0 (1.19.0 available)
+ flame 1.18.0
+ flame_forge2d 0.18.1
+ flame_kenney_xml 0.1.0
  flutter_lints 3.0.2 (4.0.0 available)
+ forge2d 0.13.0
  leak_tracker 10.0.4 (10.0.5 available)
  leak_tracker_flutter_testing 3.0.3 (3.0.5 available)
  lints 3.0.0 (4.0.0 available)
  material_color_utilities 0.8.0 (0.12.0 available)
  meta 1.12.0 (1.15.0 available)
+ ordered_set 5.0.3 (6.0.1 available)
+ petitparser 6.0.2
  test_api 0.7.0 (0.7.3 available)
  vm_service 14.2.1 (14.2.4 available)
+ xml 6.5.0
Changed 8 dependencies!
10 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

flame 패키지는 익숙하지만 다른 세 패키지에는 설명이 필요할 수 있습니다. characters 패키지는 UTF8을 준수하는 방식으로 파일 경로를 조작하는 데 사용됩니다. flame_forge2d 패키지는 Flame과 잘 작동하는 방식으로 Forge2D 기능을 노출합니다. 마지막으로 xml 패키지는 다양한 위치에서 XML 콘텐츠를 사용하고 수정하는 데 사용됩니다.

프로젝트를 열고 lib/main.dart 파일의 콘텐츠를 다음으로 바꿉니다.

lib/main.dart

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

void main() {
  runApp(
    GameWidget.controlled(
      gameFactory: FlameGame.new,
    ),
  );
}

이렇게 하면 FlameGame 인스턴스를 인스턴스화하는 GameWidget로 앱이 시작됩니다. 이 Codelab에는 게임 인스턴스의 상태를 사용하여 실행 중인 게임에 관한 정보를 표시하는 Flutter 코드가 없으므로 이 단순화된 부트스트랩이 원활하게 작동합니다.

선택사항: macOS 전용 사이드 퀘스트 진행하기

이 프로젝트의 스크린샷은 macOS 데스크톱 앱 게임의 스크린샷입니다. 앱의 제목 표시줄이 전반적인 경험을 방해하지 않도록 하려면 macOS 실행기의 프로젝트 구성을 수정하여 제목 표시줄을 숨길 수 있습니다.

단계는 다음과 같습니다.

  1. bin/modify_macos_config.dart 파일을 만들고 다음 콘텐츠를 추가합니다.

bin/modify_macos_config.dart를 입력하세요.

import 'dart:io';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('macos/Runner/Base.lproj/MainMenu.xib');
  var document = XmlDocument.parse(file.readAsStringSync());
  document.xpath('//document/objects/window').first
    ..setAttribute('titlebarAppearsTransparent', 'YES')
    ..setAttribute('titleVisibility', 'hidden');
  document
      .xpath('//document/objects/window/windowStyleMask')
      .first
      .setAttribute('fullSizeContentView', 'YES');
  file.writeAsStringSync(document.toString());
}

이 파일은 게임의 런타임 코드베이스의 일부가 아니므로 lib 디렉터리에 없습니다. 프로젝트를 수정하는 데 사용되는 명령줄 도구입니다.

  1. 프로젝트 기본 디렉터리에서 다음과 같이 도구를 실행합니다.
$ dart bin/modify_macos_config.dart

모든 것이 계획대로 진행되면 프로그램은 명령줄에서 출력을 생성하지 않습니다. 하지만 제목 표시줄이 표시되지 않고 Flame 게임이 전체 창을 차지하여 게임을 실행하도록 macos/Runner/Base.lproj/MainMenu.xib 구성 파일을 수정합니다.

게임을 실행하여 모든 것이 제대로 작동하는지 확인합니다. 그러면 빈 검은색 배경만 있는 새 창이 표시됩니다.

포그라운드에 아무것도 없는 검은색 배경의 앱 창

3. 이미지 확장 소재 추가

이미지 추가하기

모든 게임에 재미를 주는 방식으로 화면을 그리려면 예술 애셋이 필요합니다. 이 Codelab에서는 Kenney.nl물리학 애셋 팩을 사용합니다. 이러한 저작물은 크리에이티브 커먼즈 CC0 라이선스를 받았지만, 지금도 케니의 팀이 계속해서 훌륭한 작업을 할 수 있도록 기부할 것을 적극 권장합니다. 도와주려고 했어.

Kenney의 애셋을 사용할 수 있도록 pubspec.yaml 구성 파일을 수정해야 합니다. 다음과 같이 수정합니다.

pubspec.yaml

name: forge2d_game
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: '>=3.3.3 <4.0.0'

dependencies:
  characters: ^1.3.0
  flame: ^1.17.0
  flame_forge2d: ^0.18.0
  flutter:
    sdk: flutter
  xml: ^6.5.0

dev_dependencies:
  flutter_lints: ^3.0.0
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true
  assets:                        # Add from here
    - assets/
    - assets/images/             # To here.

Flame에는 이미지 애셋이 assets/images에 있을 것으로 예상하지만 이는 다르게 구성할 수 있습니다. 자세한 내용은 Flame의 이미지 문서를 참고하세요. 이제 경로를 구성했으므로 프로젝트 자체에 경로를 추가해야 합니다. 이를 위한 한 가지 방법은 다음과 같이 명령줄을 사용하는 것입니다.

$ mkdir -p assets/images

mkdir 명령어에서 출력이 표시되지 않지만 새 디렉터리가 편집기나 파일 탐색기에 표시되어야 합니다.

다운로드한 kenney_physics-assets.zip 파일을 펼치면 다음과 같이 표시됩니다.

PNG/Backgrounds 디렉터리가 강조 표시된 kenney_physics-assets 팩의 파일 목록이 확장됨

PNG/Backgrounds 디렉터리에서 colored_desert.png, colored_grass.png, colored_land.png, colored_shroom.png 파일을 프로젝트의 assets/images 디렉터리로 복사합니다.

스프라이트 시트도 있습니다. 이 파일은 스프라이트 시트 이미지에서 작은 이미지가 있을 수 있는 위치를 설명하는 XML 파일과 PNG 이미지의 조합입니다. 스프라이트 시트는 수백 개의 개별 이미지 파일이 아닌 수십 개의 이미지 파일을 로드하는 대신 단일 파일만 로드하여 로드 시간을 줄이는 기법입니다.

확장된 kenney_physics-assets 팩의 파일 목록에 Spritesheet 디렉터리가 강조 표시되어 있습니다.

spritesheet_aliens.png, spritesheet_elements.png, spritesheet_tiles.png를 프로젝트의 assets/images 디렉터리에 복사합니다. 이 단계에서 spritesheet_aliens.xml, spritesheet_elements.xml, spritesheet_tiles.xml 파일도 프로젝트의 assets 디렉터리에 복사합니다. 프로젝트는 다음과 같이 표시됩니다.

assets 디렉터리가 강조표시된 forge2d_game 프로젝트 디렉터리의 파일 목록

배경을 칠합니다.

이제 프로젝트에 이미지 애셋이 추가되었으므로 이를 화면에 표시합니다. 화면에는 하나의 이미지가 있습니다. 다음 단계에서 더 많은 작업을 수행할 수 있습니다.

lib/components라는 새 디렉터리에 background.dart라는 파일을 만들고 다음 콘텐츠를 추가합니다.

lib/components/background.dart

import 'dart:math';
import 'package:flame/components.dart';
import 'game.dart';

class Background extends SpriteComponent with HasGameReference<MyPhysicsGame> {
  Background({required super.sprite})
      : super(
          anchor: Anchor.center,
          position: Vector2(0, 0),
        );

  @override
  void onMount() {
    super.onMount();

    size = Vector2.all(max(
      game.camera.visibleWorldRect.width,
      game.camera.visibleWorldRect.height,
    ));
  }
}

이 구성요소는 특수한 SpriteComponent입니다. 이 앱은 Kenney.nl의 네 개 배경 이미지 중 하나를 표시합니다. 이 코드에는 가정을 단순화하는 몇 가지 방법이 있습니다. 첫 번째는 정사각형 이미지로, Kenney의 배경 이미지 4개는 모두 정사각형입니다. 두 번째는 표시되는 세계의 크기가 절대 변경되지 않는다는 것입니다. 그렇지 않으면 이 구성요소가 게임 크기 조절 이벤트를 처리해야 합니다. 세 번째 가정은 위치 (0,0)이 화면 중앙에 있다고 가정합니다. 이러한 가정을 하려면 게임의 CameraComponent를 구체적으로 구성해야 합니다.

lib/components 디렉터리에 game.dart라는 또 다른 새 파일을 만듭니다.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));

    return super.onLoad();
  }
}

이곳에서는 많은 일이 벌어지고 있습니다. MyPhysicsGame 클래스부터 시작해 보겠습니다. 이전 Codelab과 달리 이 Codelab은 FlameGame가 아닌 Forge2DGame를 확장합니다. Forge2DGame 자체는 몇 가지 흥미로운 조정으로 FlameGame를 확장합니다. 첫 번째는 zoom가 기본적으로 10으로 설정된다는 것입니다. 이 zoom 설정은 Box2D 스타일 물리 시뮬레이션 엔진이 잘 작동하는 유용한 값의 범위와 관련이 있습니다. 엔진은 MKS 시스템을 사용하여 작성되며, 여기서 단위가 미터, 킬로그램, 초 단위라고 가정됩니다. 객체에 대해 눈에 띄는 수학 오차가 보이지 않는 범위는 0.1m~10m입니다. 일정 수준의 축소 없이 픽셀 크기를 직접 입력하면 Forge2D는 유용한 범위를 벗어날 수 있습니다. 유용한 요약은 탄산음료 범위 내의 물체를 버스까지 시뮬레이션한다고 생각하면 됩니다.

여기에서는 CameraComponent의 해상도를 800x600 가상 픽셀로 고정하여 백그라운드 구성요소에서 수행한 가정을 충족합니다. 즉, 게임 영역의 중심은 (0, 0)을 중심으로 너비 80단위,높이 60단위가 됩니다. 이는 표시되는 해상도에는 영향을 미치지 않지만 게임 장면에서 객체를 배치하는 위치에는 영향을 줍니다.

camera 생성자 인수 옆에 물리학적으로 정렬된 gravity이라는 또 다른 인수도 있습니다. 중력이 x 0, y 10인 Vector2로 설정되어 있습니다. 10은 일반적으로 허용되는 초당 9.81미터의 중력 값에 근접한 값입니다. 중력이 양수 10으로 설정되어 있다는 사실은 이 시스템에서 Y축의 방향이 내려가 있음을 나타냅니다. 일반적으로 Box2D와 다르지만 이는 Flame이 일반적으로 구성되는 방식과 일치합니다.

다음은 onLoad 메서드입니다. 이 메서드는 디스크에서 이미지 애셋을 로드하므로 적합한 비동기식입니다. images.load 호출은 Future<Image>을 반환하고 부작용으로 게임 객체에 로드된 이미지를 캐시합니다. 이러한 future는 함께 수집되고 Futures.wait 정적 메서드를 사용하여 단일 단위로 대기합니다. 그런 다음 반환된 이미지 목록이 개별 이름과 일치하는 패턴을 찾습니다.

그런 다음 스프라이트 시트 이미지는 일련의 XmlSpriteSheet 객체에 입력됩니다. 이 객체는 스프라이트 시트에 포함된 개별적으로 이름이 지정된 스프라이트를 가져옵니다. XmlSpriteSheet 클래스는 flame_kenney_xml 패키지에 정의되어 있습니다.

지금까지 lib/main.dart를 약간만 수정하면 화면에 이미지를 표시할 수 있습니다.

lib/main.dart

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

import 'components/game.dart';                             // Add this import

void main() {
  runApp(
    GameWidget.controlled(
      gameFactory: MyPhysicsGame.new,                      // Modify this line
    ),
  );
}

이 간단한 변경을 통해 이제 게임을 다시 실행하여 화면의 배경을 볼 수 있습니다. CameraComponent.withFixedResolution() 카메라 인스턴스는 게임이 작동하는 800x600 비율을 만드는 데 필요한 레터박스를 추가합니다.

구불구불한 푸른 언덕과 이상하게 추상적인 나무의 배경 이미지가 표시된 앱 창

4. 지면 추가

기반 구축

중력이 있다면 화면에서 물체가 떨어지기 전에 물체를 잡아내야 합니다. 물론 화면에서 벗어나는 것이 게임 디자인의 일부인 경우는 예외입니다. lib/components 디렉터리에 새 ground.dart 파일을 만들고 다음을 추가합니다.

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const groundSize = 7.0;

class Ground extends BodyComponent {
  Ground(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(groundSize),
              position: Vector2(0, 0),
            ),
          ],
        );
}

Ground 구성요소는 BodyComponent에서 파생됩니다. Forge2D 본체에서 중요한 것은 2차원 물리적 시뮬레이션의 일부인 객체입니다. 이 구성요소의 BodyDefBodyType.static를 갖도록 지정됩니다.

Forge2D의 신체에는 세 가지 유형이 있습니다. 정적인 물체는 움직이지 않습니다. 사실상 질량이 0이고(중력에 반응하지 않음) 무한한 질량으로 인해 아무리 무거워도 다른 물체에 부딪혀도 움직이지 않습니다. 이렇게 하면 정적인 물체가 움직이지 않기 때문에 지면에 적합합니다.

나머지 두 가지 유형의 신체는 운동학적 몸과 동적인 몸입니다. 동적 신체는 완전히 시뮬레이션된 물체로, 중력 및 부딪히는 물체에 반응합니다. 이 Codelab의 나머지 부분에서는 많은 동적 본문을 볼 수 있습니다. 운동 몸은 정적과 역동적 사이의 중간 지점입니다. 움직이지만 중력이나 다른 물체에 부딪히는 물체에는 반응하지 않습니다. 유용하지만 이 Codelab에서는 다루지 않습니다.

몸 자체는 그다지 많은 일을 하지 않습니다. 물질을 갖기 위해서는 몸에 연결된 모양이 있어야 합니다. 이 경우 이 본문에는 연결된 도형이 하나 있으며 PolygonShapeBoxXY로 설정되어 있습니다. 이 유형의 상자는 회전 지점을 중심으로 회전할 수 있는 BoxXY로 설정된 PolygonShape와 달리 세계를 기준으로 정렬된 축입니다. 이 경우에도 유용하지만 이 Codelab에서는 다루지 않습니다. 도형과 본체가 고정 장치로 결합되어 friction와 같은 요소를 시스템에 추가하는 데 유용합니다.

기본적으로 본문은 디버깅에 유용한 방식으로 연결된 도형을 렌더링하지만 게임플레이에는 적합하지 않습니다. super 인수 renderBodyfalse로 설정하면 이 디버그 렌더링이 사용 중지됩니다. 이 본문에 게임 내 렌더링을 제공하는 것은 하위 SpriteComponent의 책임입니다.

Ground 구성요소를 게임에 추가하려면 다음과 같이 game.dart 파일을 수정합니다.

lib/components/game.dart

import 'dart:async';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'ground.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {                               // Add from here...
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }                                                        // To here.
}

이 edit은 List 컨텍스트 내부의 for 루프를 사용하고 결과 Ground 구성요소 목록을 worldaddAll 메서드에 전달하여 일련의 Ground 구성요소를 월드에 추가합니다.

이제 게임을 실행하면 배경과 지면이 표시됩니다.

배경과 지면 레이어가 있는 애플리케이션 창

5. 벽돌 추가

장벽 건설

땅은 정적인 몸의 예를 보여줬습니다. 이제 첫 번째 동적 구성요소를 만들어 보겠습니다. Forge2D의 동적 구성요소는 플레이어 경험의 초석이며, 주변 세계와 움직이고 상호작용하는 요소입니다. 이 단계에서는 벽돌을 소개합니다. 벽돌은 벽돌이 모여 화면에서 무작위로 선택됩니다. 친구가 넘어지면서 서로 부딪히는 모습을 볼 수 있습니다.

요소 스프라이트 시트로 벽돌이 만들어집니다. assets/spritesheet_elements.xml의 스프라이트 시트 설명을 살펴보면 흥미로운 문제가 있음을 알 수 있습니다. 이름은 그다지 유용하지 않은 것 같습니다. 재료의 유형, 크기, 손상 정도에 따라 벽돌을 선택할 수 있다면 유용할 것입니다. 다행히도 도움을 주는 요정이 잠시 시간을 들여 파일 이름 지정 패턴을 파악하고 모든 사람이 더 쉽게 할 수 있는 도구를 만들었습니다. bin 디렉터리에 새 파일 generate_brick_file_names.dart를 만들고 다음 콘텐츠를 추가합니다.

bin/generate_brick_file_names.dart

import 'dart:io';
import 'package:equatable/equatable.dart';
import 'package:xml/xml.dart';
import 'package:xml/xpath.dart';

void main() {
  final file = File('assets/spritesheet_elements.xml');
  final rects = <String, Rect>{};
  final document = XmlDocument.parse(file.readAsStringSync());
  for (final node in document.xpath('//TextureAtlas/SubTexture')) {
    final name = node.getAttribute('name')!;
    rects[name] = Rect(
      x: int.parse(node.getAttribute('x')!),
      y: int.parse(node.getAttribute('y')!),
      width: int.parse(node.getAttribute('width')!),
      height: int.parse(node.getAttribute('height')!),
    );
  }
  print(generateBrickFileNames(rects));
}

class Rect extends Equatable {
  final int x;
  final int y;
  final int width;
  final int height;
  const Rect(
      {required this.x,
      required this.y,
      required this.width,
      required this.height});

  Size get size => Size(width, height);

  @override
  List<Object?> get props => [x, y, width, height];

  @override
  bool get stringify => true;
}

class Size extends Equatable {
  final int width;
  final int height;
  const Size(this.width, this.height);

  @override
  List<Object?> get props => [width, height];

  @override
  bool get stringify => true;
}

String generateBrickFileNames(Map<String, Rect> rects) {
  final groups = <Size, List<String>>{};
  for (final entry in rects.entries) {
    groups.putIfAbsent(entry.value.size, () => []).add(entry.key);
  }
  final buff = StringBuffer();
  buff.writeln('''
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {''');
  for (final entry in groups.entries) {
    final size = entry.key;
    final entries = entry.value;
    entries.sort();
    for (final type in ['Explosive', 'Glass', 'Metal', 'Stone', 'Wood']) {
      var filtered = entries.where((element) => element.contains(type));
      if (filtered.length == 5) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(0)}',
        BrickDamage.some: '${filtered.elementAt(1)}',
        BrickDamage.lots: '${filtered.elementAt(4)}',
      },''');
      } else if (filtered.length == 10) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(3)}',
        BrickDamage.some: '${filtered.elementAt(4)}',
        BrickDamage.lots: '${filtered.elementAt(9)}',
      },''');
      } else if (filtered.length == 15) {
        buff.writeln('''
    (BrickType.${type.toLowerCase()}, BrickSize.size${size.width}x${size.height}) => {
        BrickDamage.none: '${filtered.elementAt(7)}',
        BrickDamage.some: '${filtered.elementAt(8)}',
        BrickDamage.lots: '${filtered.elementAt(13)}',
      },''');
      }
    }
  }
  buff.writeln('''
  };
}''');
  return buff.toString();
}

종속 항목 누락에 관한 경고 또는 오류가 편집기에서 표시됩니다. 다음과 같이 추가합니다.

$ flutter pub add equatable

이제 다음과 같이 이 프로그램을 실행할 수 있습니다.

$ dart run bin/generate_brick_file_names.dart
Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
[Content elided...]
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

이 도구는 유용한 스프라이트 시트 설명 파일을 파싱하여 화면에 넣으려는 각 벽돌에 맞는 이미지 파일을 선택하는 데 사용할 수 있는 Dart 코드로 변환했습니다. 유용하네요!

다음 콘텐츠로 brick.dart 파일을 만듭니다.

lib/components/brick.dart

import 'dart:math';
import 'dart:ui' as ui;

import 'package:flame/components.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

const brickScale = 0.5;

enum BrickType {
  explosive(density: 1, friction: 0.5),
  glass(density: 0.5, friction: 0.2),
  metal(density: 1, friction: 0.4),
  stone(density: 2, friction: 1),
  wood(density: 0.25, friction: 0.6);

  final double density;
  final double friction;

  const BrickType({required this.density, required this.friction});
  static BrickType get randomType => values[Random().nextInt(values.length)];
}

enum BrickSize {
  size70x70(ui.Size(70, 70)),
  size140x70(ui.Size(140, 70)),
  size220x70(ui.Size(220, 70)),
  size70x140(ui.Size(70, 140)),
  size140x140(ui.Size(140, 140)),
  size220x140(ui.Size(220, 140)),
  size140x220(ui.Size(140, 220)),
  size70x220(ui.Size(70, 220));

  final ui.Size size;

  const BrickSize(this.size);
  static BrickSize get randomSize => values[Random().nextInt(values.length)];
}

enum BrickDamage { none, some, lots }

Map<BrickDamage, String> brickFileNames(BrickType type, BrickSize size) {
  return switch ((type, size)) {
    (BrickType.explosive, BrickSize.size140x70) => {
        BrickDamage.none: 'elementExplosive009.png',
        BrickDamage.some: 'elementExplosive012.png',
        BrickDamage.lots: 'elementExplosive050.png',
      },
    (BrickType.glass, BrickSize.size140x70) => {
        BrickDamage.none: 'elementGlass010.png',
        BrickDamage.some: 'elementGlass013.png',
        BrickDamage.lots: 'elementGlass048.png',
      },
    (BrickType.metal, BrickSize.size140x70) => {
        BrickDamage.none: 'elementMetal009.png',
        BrickDamage.some: 'elementMetal012.png',
        BrickDamage.lots: 'elementMetal050.png',
      },
    (BrickType.stone, BrickSize.size140x70) => {
        BrickDamage.none: 'elementStone009.png',
        BrickDamage.some: 'elementStone012.png',
        BrickDamage.lots: 'elementStone047.png',
      },
    (BrickType.wood, BrickSize.size140x70) => {
        BrickDamage.none: 'elementWood011.png',
        BrickDamage.some: 'elementWood014.png',
        BrickDamage.lots: 'elementWood054.png',
      },
    (BrickType.explosive, BrickSize.size70x70) => {
        BrickDamage.none: 'elementExplosive011.png',
        BrickDamage.some: 'elementExplosive014.png',
        BrickDamage.lots: 'elementExplosive049.png',
      },
    (BrickType.glass, BrickSize.size70x70) => {
        BrickDamage.none: 'elementGlass011.png',
        BrickDamage.some: 'elementGlass012.png',
        BrickDamage.lots: 'elementGlass046.png',
      },
    (BrickType.metal, BrickSize.size70x70) => {
        BrickDamage.none: 'elementMetal011.png',
        BrickDamage.some: 'elementMetal014.png',
        BrickDamage.lots: 'elementMetal049.png',
      },
    (BrickType.stone, BrickSize.size70x70) => {
        BrickDamage.none: 'elementStone011.png',
        BrickDamage.some: 'elementStone014.png',
        BrickDamage.lots: 'elementStone046.png',
      },
    (BrickType.wood, BrickSize.size70x70) => {
        BrickDamage.none: 'elementWood010.png',
        BrickDamage.some: 'elementWood013.png',
        BrickDamage.lots: 'elementWood045.png',
      },
    (BrickType.explosive, BrickSize.size220x70) => {
        BrickDamage.none: 'elementExplosive013.png',
        BrickDamage.some: 'elementExplosive016.png',
        BrickDamage.lots: 'elementExplosive051.png',
      },
    (BrickType.glass, BrickSize.size220x70) => {
        BrickDamage.none: 'elementGlass014.png',
        BrickDamage.some: 'elementGlass017.png',
        BrickDamage.lots: 'elementGlass049.png',
      },
    (BrickType.metal, BrickSize.size220x70) => {
        BrickDamage.none: 'elementMetal013.png',
        BrickDamage.some: 'elementMetal016.png',
        BrickDamage.lots: 'elementMetal051.png',
      },
    (BrickType.stone, BrickSize.size220x70) => {
        BrickDamage.none: 'elementStone013.png',
        BrickDamage.some: 'elementStone016.png',
        BrickDamage.lots: 'elementStone048.png',
      },
    (BrickType.wood, BrickSize.size220x70) => {
        BrickDamage.none: 'elementWood012.png',
        BrickDamage.some: 'elementWood015.png',
        BrickDamage.lots: 'elementWood047.png',
      },
    (BrickType.explosive, BrickSize.size70x140) => {
        BrickDamage.none: 'elementExplosive017.png',
        BrickDamage.some: 'elementExplosive022.png',
        BrickDamage.lots: 'elementExplosive052.png',
      },
    (BrickType.glass, BrickSize.size70x140) => {
        BrickDamage.none: 'elementGlass018.png',
        BrickDamage.some: 'elementGlass023.png',
        BrickDamage.lots: 'elementGlass050.png',
      },
    (BrickType.metal, BrickSize.size70x140) => {
        BrickDamage.none: 'elementMetal017.png',
        BrickDamage.some: 'elementMetal022.png',
        BrickDamage.lots: 'elementMetal052.png',
      },
    (BrickType.stone, BrickSize.size70x140) => {
        BrickDamage.none: 'elementStone017.png',
        BrickDamage.some: 'elementStone022.png',
        BrickDamage.lots: 'elementStone049.png',
      },
    (BrickType.wood, BrickSize.size70x140) => {
        BrickDamage.none: 'elementWood016.png',
        BrickDamage.some: 'elementWood021.png',
        BrickDamage.lots: 'elementWood048.png',
      },
    (BrickType.explosive, BrickSize.size140x140) => {
        BrickDamage.none: 'elementExplosive018.png',
        BrickDamage.some: 'elementExplosive023.png',
        BrickDamage.lots: 'elementExplosive053.png',
      },
    (BrickType.glass, BrickSize.size140x140) => {
        BrickDamage.none: 'elementGlass019.png',
        BrickDamage.some: 'elementGlass024.png',
        BrickDamage.lots: 'elementGlass051.png',
      },
    (BrickType.metal, BrickSize.size140x140) => {
        BrickDamage.none: 'elementMetal018.png',
        BrickDamage.some: 'elementMetal023.png',
        BrickDamage.lots: 'elementMetal053.png',
      },
    (BrickType.stone, BrickSize.size140x140) => {
        BrickDamage.none: 'elementStone018.png',
        BrickDamage.some: 'elementStone023.png',
        BrickDamage.lots: 'elementStone050.png',
      },
    (BrickType.wood, BrickSize.size140x140) => {
        BrickDamage.none: 'elementWood017.png',
        BrickDamage.some: 'elementWood022.png',
        BrickDamage.lots: 'elementWood049.png',
      },
    (BrickType.explosive, BrickSize.size220x140) => {
        BrickDamage.none: 'elementExplosive019.png',
        BrickDamage.some: 'elementExplosive024.png',
        BrickDamage.lots: 'elementExplosive054.png',
      },
    (BrickType.glass, BrickSize.size220x140) => {
        BrickDamage.none: 'elementGlass020.png',
        BrickDamage.some: 'elementGlass025.png',
        BrickDamage.lots: 'elementGlass052.png',
      },
    (BrickType.metal, BrickSize.size220x140) => {
        BrickDamage.none: 'elementMetal019.png',
        BrickDamage.some: 'elementMetal024.png',
        BrickDamage.lots: 'elementMetal054.png',
      },
    (BrickType.stone, BrickSize.size220x140) => {
        BrickDamage.none: 'elementStone019.png',
        BrickDamage.some: 'elementStone024.png',
        BrickDamage.lots: 'elementStone051.png',
      },
    (BrickType.wood, BrickSize.size220x140) => {
        BrickDamage.none: 'elementWood018.png',
        BrickDamage.some: 'elementWood023.png',
        BrickDamage.lots: 'elementWood050.png',
      },
    (BrickType.explosive, BrickSize.size70x220) => {
        BrickDamage.none: 'elementExplosive020.png',
        BrickDamage.some: 'elementExplosive025.png',
        BrickDamage.lots: 'elementExplosive055.png',
      },
    (BrickType.glass, BrickSize.size70x220) => {
        BrickDamage.none: 'elementGlass021.png',
        BrickDamage.some: 'elementGlass026.png',
        BrickDamage.lots: 'elementGlass053.png',
      },
    (BrickType.metal, BrickSize.size70x220) => {
        BrickDamage.none: 'elementMetal020.png',
        BrickDamage.some: 'elementMetal025.png',
        BrickDamage.lots: 'elementMetal055.png',
      },
    (BrickType.stone, BrickSize.size70x220) => {
        BrickDamage.none: 'elementStone020.png',
        BrickDamage.some: 'elementStone025.png',
        BrickDamage.lots: 'elementStone052.png',
      },
    (BrickType.wood, BrickSize.size70x220) => {
        BrickDamage.none: 'elementWood019.png',
        BrickDamage.some: 'elementWood024.png',
        BrickDamage.lots: 'elementWood051.png',
      },
    (BrickType.explosive, BrickSize.size140x220) => {
        BrickDamage.none: 'elementExplosive021.png',
        BrickDamage.some: 'elementExplosive026.png',
        BrickDamage.lots: 'elementExplosive056.png',
      },
    (BrickType.glass, BrickSize.size140x220) => {
        BrickDamage.none: 'elementGlass022.png',
        BrickDamage.some: 'elementGlass027.png',
        BrickDamage.lots: 'elementGlass054.png',
      },
    (BrickType.metal, BrickSize.size140x220) => {
        BrickDamage.none: 'elementMetal021.png',
        BrickDamage.some: 'elementMetal026.png',
        BrickDamage.lots: 'elementMetal056.png',
      },
    (BrickType.stone, BrickSize.size140x220) => {
        BrickDamage.none: 'elementStone021.png',
        BrickDamage.some: 'elementStone026.png',
        BrickDamage.lots: 'elementStone053.png',
      },
    (BrickType.wood, BrickSize.size140x220) => {
        BrickDamage.none: 'elementWood020.png',
        BrickDamage.some: 'elementWood025.png',
        BrickDamage.lots: 'elementWood052.png',
      },
  };
}

class Brick extends BodyComponent {
  Brick({
    required this.type,
    required this.size,
    required BrickDamage damage,
    required Vector2 position,
    required Map<BrickDamage, Sprite> sprites,
  })  : _damage = damage,
        _sprites = sprites,
        super(
            renderBody: false,
            bodyDef: BodyDef()
              ..position = position
              ..type = BodyType.dynamic,
            fixtureDefs: [
              FixtureDef(
                PolygonShape()
                  ..setAsBoxXY(
                    size.size.width / 20 * brickScale,
                    size.size.height / 20 * brickScale,
                  ),
              )
                ..restitution = 0.4
                ..density = type.density
                ..friction = type.friction
            ]);

  late final SpriteComponent _spriteComponent;

  final BrickType type;
  final BrickSize size;
  final Map<BrickDamage, Sprite> _sprites;

  BrickDamage _damage;
  BrickDamage get damage => _damage;
  set damage(BrickDamage value) {
    _damage = value;
    _spriteComponent.sprite = _sprites[value];
  }

  @override
  Future<void> onLoad() {
    _spriteComponent = SpriteComponent(
      anchor: Anchor.center,
      scale: Vector2.all(1),
      sprite: _sprites[_damage],
      size: size.size.toVector2() / 10 * brickScale,
      position: Vector2(0, 0),
    );
    add(_spriteComponent);
    return super.onLoad();
  }
}

이제 위에서 생성한 Dart 코드가 이 코드베이스에 통합되어 재질, 크기, 상태에 따라 벽돌 이미지를 쉽고 빠르게 선택할 수 있는 방법을 확인할 수 있습니다. enum를 지나 Brick 구성요소 자체를 살펴보면 이 코드의 대부분이 이전 단계의 Ground 구성요소와 상당히 친숙한 것으로 보입니다. 여기에는 벽돌이 손상될 수 있도록 변경 가능한 상태가 있습니다. 다만 이 상태는 독자를 위한 연습으로 남겨 둡니다.

화면에 벽돌을 표시할 시간입니다. 다음과 같이 game.dart 파일을 수정합니다.

lib/components/game.dart

import 'dart:async';
import 'dart:math';                                        // Add this import

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';                                       // Add this import
import 'ground.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());                                // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();                                // Add from here...

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }                                                        // To here.
}

이 코드 추가는 Ground 구성요소를 추가하는 데 사용한 코드와 약간 다릅니다. 이번에는 Brick가 시간 경과에 따라 무작위 클러스터에 추가됩니다. 여기에는 두 부분이 있습니다. 첫 번째는 BrickawaitFuture.delayed에 추가하는 메서드가 sleep() 호출의 비동기식인 것입니다. 그러나 이 작업을 실행하는 두 번째 부분이 있습니다. onLoad 메서드에서 addBricks 호출이 await되지 않습니다. 맞다면 모든 블록이 화면에 표시될 때까지 onLoad 메서드가 완료되지 않습니다. unawaited 호출에서 addBricks 호출을 래핑하면 린터가 만족스러워지고 향후 프로그래머가 의도를 명확하게 알 수 있습니다. 이 메서드가 반환될 때까지 기다리지 않는 것이 중요합니다.

게임을 실행하면 벽돌이 서로 부딪히고 땅에 쏟아지는 모습을 볼 수 있습니다.

배경에 초록색 언덕, 지면 레이어, 지면에 닿는 블록이 있는 앱 창

6. 플레이어 추가

벽돌을 파헤치는 외계인들

처음 몇 번은 벽돌이 뒤틀리는 모습을 보는 건 재미있지만, 세상과 상호작용할 수 있는 아바타를 플레이어에게 주면 이 게임이 더 재미있을 것 같아. 외계인이 벽돌로 날아가 버리면 어떨까요?

lib/components 디렉터리에 새 player.dart 파일을 만들고 다음을 추가합니다.

lib/components/player.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

const playerSize = 5.0;

enum PlayerColor {
  pink,
  blue,
  green,
  yellow;

  static PlayerColor get randomColor =>
      PlayerColor.values[Random().nextInt(PlayerColor.values.length)];

  String get fileName =>
      'alien${toString().split('.').last.capitalize}_round.png';
}

class Player extends BodyComponent with DragCallbacks {
  Player(Vector2 position, Sprite sprite)
      : _sprite = sprite,
        super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static
            ..angularDamping = 0.1
            ..linearDamping = 0.1,
          fixtureDefs: [
            FixtureDef(CircleShape()..radius = playerSize / 2)
              ..restitution = 0.4
              ..density = 0.75
              ..friction = 0.5
          ],
        );

  final Sprite _sprite;

  @override
  Future<void> onLoad() {
    addAll([
      CustomPainterComponent(
        painter: _DragPainter(this),
        anchor: Anchor.center,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      ),
      SpriteComponent(
        anchor: Anchor.center,
        sprite: _sprite,
        size: Vector2(playerSize, playerSize),
        position: Vector2(0, 0),
      )
    ]);
    return super.onLoad();
  }

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

    if (!body.isAwake) {
      removeFromParent();
    }

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }

  Vector2 _dragStart = Vector2.zero();
  Vector2 _dragDelta = Vector2.zero();
  Vector2 get dragDelta => _dragDelta;

  @override
  void onDragStart(DragStartEvent event) {
    super.onDragStart(event);
    if (body.bodyType == BodyType.static) {
      _dragStart = event.localPosition;
    }
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    if (body.bodyType == BodyType.static) {
      _dragDelta = event.localEndPosition - _dragStart;
    }
  }

  @override
  void onDragEnd(DragEndEvent event) {
    super.onDragEnd(event);
    if (body.bodyType == BodyType.static) {
      children
          .whereType<CustomPainterComponent>()
          .firstOrNull
          ?.removeFromParent();
      body.setType(BodyType.dynamic);
      body.applyLinearImpulse(_dragDelta * -50);
      add(RemoveEffect(
        delay: 5.0,
      ));
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

class _DragPainter extends CustomPainter {
  _DragPainter(this.player);

  final Player player;

  @override
  void paint(Canvas canvas, Size size) {
    if (player.dragDelta != Vector2.zero()) {
      var center = size.center(Offset.zero);
      canvas.drawLine(
          center,
          center + (player.dragDelta * -1).toOffset(),
          Paint()
            ..color = Colors.orange.withOpacity(0.7)
            ..strokeWidth = 0.4
            ..strokeCap = StrokeCap.round);
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

이는 이전 단계의 Brick 구성요소에서 한 단계 업그레이드된 것입니다. 이 Player 구성요소에는 두 개의 하위 구성요소, 즉 알아야 하는 SpriteComponent와 새로운 CustomPainterComponent가 있습니다. CustomPainter 개념은 Flutter에서 가져온 것으로 캔버스에 그릴 수 있습니다. 여기에서 둥근 외계인이 날아갈 때 어디로 날아갈지 플레이어에게 피드백을 제공하는 데 사용됩니다.

플레이어는 어떻게 외계인을 날리기 시작할까요? 플레이어 구성요소가 DragCallbacks 콜백으로 감지하는 드래그 동작 사용 네 가운데에 있는 독수리가 여기에서 다른 뭔가를 알아차렸을 거야.

Ground 구성요소는 정적 본문이었고, 벽돌 구성요소는 동적 본체였습니다. 여기서 플레이어는 이 두 가지를 조합한 것입니다. 플레이어는 정적인 상태로 시작하여 플레이어가 드래그할 때까지 기다립니다. 드래그 버튼을 놓으면 정적에서 동적으로 바뀌고 드래그에 비례하여 선형 충동을 더해 외계 아바타가 날 수 있게 됩니다.

Player 구성요소에는 경계에서 벗어나거나, 절전 모드이거나, 타임아웃되는 경우 화면에서 삭제하는 코드도 있습니다. 여기서 의도는 플레이어가 외계인에게 날아가서 무슨 일이 일어날지 확인한 후 한 번 더 가볼 수 있도록 하는 것입니다.

다음과 같이 game.dart를 수정하여 Player 구성요소를 게임에 통합합니다.

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';

import 'background.dart';
import 'brick.dart';
import 'ground.dart';
import 'player.dart';                                      // Add this import

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks());
    await addPlayer();                                     // Add this line

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(             // Add from here...
        Player(
          Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
          aliens.getSprite(PlayerColor.randomColor.fileName),
        ),
      );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted && world.children.whereType<Player>().isEmpty) {
      addPlayer();
    }
  }                                                        // To here.
}

게임에 플레이어를 추가하는 것은 이전 구성요소와 유사하지만 한 가지 추가 사항이 있습니다. 플레이어의 외계인은 특정 조건에서 자신을 게임에서 삭제하도록 설계되었으므로, 여기에는 게임에 Player 구성요소가 없는지 확인하고 있으면 다시 추가하는 업데이트 핸들러가 있습니다. 게임을 실행하는 것은 다음과 같습니다.

배경에 초록색 언덕, 지면 레이어, 지면의 블록, 비행 중인 플레이어 아바타가 있는 앱 창

7. 영향력에 대한 반응

적 추가

정적 객체와 동적 객체가 서로 상호작용하는 것을 확인했습니다. 하지만 제대로 어딘가에 도달하려면 충돌이 발생할 때 코드에서 콜백을 가져와야 합니다. 그 방법을 알아보겠습니다. 플레이어가 상대해야 할 적을 소개하겠습니다. 이렇게 하면 게임에서 모든 적을 제거하여 성공할 수 있는 경로가 생깁니다.

lib/components 디렉터리에 enemy.dart 파일을 만들고 다음을 추가합니다.

lib/components/enemy.dart

import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';

import 'body_component_with_user_data.dart';

const enemySize = 5.0;

enum EnemyColor {
  pink(color: 'pink', boss: false),
  blue(color: 'blue', boss: false),
  green(color: 'green', boss: false),
  yellow(color: 'yellow', boss: false),
  pinkBoss(color: 'pink', boss: true),
  blueBoss(color: 'blue', boss: true),
  greenBoss(color: 'green', boss: true),
  yellowBoss(color: 'yellow', boss: true);

  final bool boss;
  final String color;

  const EnemyColor({required this.color, required this.boss});

  static EnemyColor get randomColor =>
      EnemyColor.values[Random().nextInt(EnemyColor.values.length)];

  String get fileName =>
      'alien${color.capitalize}_${boss ? 'suit' : 'square'}.png';
}

class Enemy extends BodyComponentWithUserData with ContactCallbacks {
  Enemy(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.dynamic,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(enemySize / 2, enemySize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(enemySize),
              position: Vector2(0, 0),
            ),
          ],
        );

  @override
  void beginContact(Object other, Contact contact) {
    var interceptVelocity =
        (contact.bodyA.linearVelocity - contact.bodyB.linearVelocity)
            .length
            .abs();
    if (interceptVelocity > 35) {
      removeFromParent();
    }

    super.beginContact(other, contact);
  }

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

    if (position.x > camera.visibleWorldRect.right + 10 ||
        position.x < camera.visibleWorldRect.left - 10) {
      removeFromParent();
    }
  }
}

extension on String {
  String get capitalize =>
      characters.first.toUpperCase() + characters.skip(1).toLowerCase().join();
}

이전에 플레이어 및 벽돌 구성요소를 사용한 상호작용에서는 이 파일의 대부분이 익숙할 것입니다. 하지만 알 수 없는 새로운 기본 클래스로 인해 편집기에 빨간색 밑줄이 표시됩니다. 다음 콘텐츠가 포함된 body_component_with_user_data.dart 파일을 lib/components에 추가하여 이 클래스를 지금 추가합니다.

lib/components/body_component_with_user_data.dart

import 'package:flame_forge2d/flame_forge2d.dart';

class BodyComponentWithUserData extends BodyComponent {
  BodyComponentWithUserData({
    super.key,
    super.bodyDef,
    super.children,
    super.fixtureDefs,
    super.paint,
    super.priority,
    super.renderBody,
  });

  @override
  Body createBody() {
    final body = world.createBody(super.bodyDef!)..userData = this;
    fixtureDefs?.forEach(body.createFixture);
    return body;
  }
}

이 기본 클래스는 Enemy 구성요소의 새로운 beginContact 콜백과 함께 신체 간의 영향에 관해 프로그래매틱 방식으로 알림을 받는 기반을 형성합니다. 실제로 영향 알림을 받으려는 구성요소를 수정해야 합니다. 따라서 Brick, Ground, Player 구성요소를 수정하여 현재 구성요소가 사용하는 BodyComponent 기본 클래스 대신 이 BodyComponentWithUserData를 사용합니다. 예를 들어 Ground 구성요소를 수정하는 방법은 다음과 같습니다.

lib/components/ground.dart

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';

import 'body_component_with_user_data.dart';               // Add this import

const groundSize = 7.0;

class Ground extends BodyComponentWithUserData {           // Edit this line
  Ground(Vector2 position, Sprite sprite)
      : super(
          renderBody: false,
          bodyDef: BodyDef()
            ..position = position
            ..type = BodyType.static,
          fixtureDefs: [
            FixtureDef(
              PolygonShape()..setAsBoxXY(groundSize / 2, groundSize / 2),
              friction: 0.3,
            )
          ],
          children: [
            SpriteComponent(
              anchor: Anchor.center,
              sprite: sprite,
              size: Vector2.all(groundSize),
              position: Vector2(0, 0),
            ),
          ],
        );
}

Forge2d에서 연락을 처리하는 방법에 관한 자세한 내용은 연락처 콜백에 관한 Forge2D 문서를 참고하세요.

경기 이기기

이제 적이 있고, 세상에서 적을 제거하는 방법이 있으므로, 이 시뮬레이션을 게임으로 전환하는 간단한 방법이 있습니다. 목표를 세워 모든 적을 제거하세요! 다음과 같이 game.dart 파일을 수정합니다.

lib/components/game.dart

import 'dart:async';
import 'dart:math';

import 'package:flame/components.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_kenney_xml/flame_kenney_xml.dart';
import 'package:flutter/material.dart';                    // Add this import

import 'background.dart';
import 'brick.dart';
import 'enemy.dart';                                       // Add this import
import 'ground.dart';
import 'player.dart';

class MyPhysicsGame extends Forge2DGame {
  MyPhysicsGame()
      : super(
          gravity: Vector2(0, 10),
          camera: CameraComponent.withFixedResolution(width: 800, height: 600),
        );

  late final XmlSpriteSheet aliens;
  late final XmlSpriteSheet elements;
  late final XmlSpriteSheet tiles;

  @override
  FutureOr<void> onLoad() async {
    final backgroundImage = await images.load('colored_grass.png');
    final spriteSheets = await Future.wait([
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_aliens.png',
        xmlPath: 'spritesheet_aliens.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_elements.png',
        xmlPath: 'spritesheet_elements.xml',
      ),
      XmlSpriteSheet.load(
        imagePath: 'spritesheet_tiles.png',
        xmlPath: 'spritesheet_tiles.xml',
      ),
    ]);

    aliens = spriteSheets[0];
    elements = spriteSheets[1];
    tiles = spriteSheets[2];

    await world.add(Background(sprite: Sprite(backgroundImage)));
    await addGround();
    unawaited(addBricks().then((_) => addEnemies()));      // Modify this line
    await addPlayer();

    return super.onLoad();
  }

  Future<void> addGround() {
    return world.addAll([
      for (var x = camera.visibleWorldRect.left;
          x < camera.visibleWorldRect.right + groundSize;
          x += groundSize)
        Ground(
          Vector2(x, (camera.visibleWorldRect.height - groundSize) / 2),
          tiles.getSprite('grass.png'),
        ),
    ]);
  }

  final _random = Random();

  Future<void> addBricks() async {
    for (var i = 0; i < 5; i++) {
      final type = BrickType.randomType;
      final size = BrickSize.randomSize;
      await world.add(
        Brick(
          type: type,
          size: size,
          damage: BrickDamage.some,
          position: Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 5 - 2.5),
              0),
          sprites: brickFileNames(type, size).map(
            (key, filename) => MapEntry(
              key,
              elements.getSprite(filename),
            ),
          ),
        ),
      );
      await Future<void>.delayed(const Duration(milliseconds: 500));
    }
  }

  Future<void> addPlayer() async => world.add(
        Player(
          Vector2(camera.visibleWorldRect.left * 2 / 3, 0),
          aliens.getSprite(PlayerColor.randomColor.fileName),
        ),
      );

  @override
  void update(double dt) {
    super.update(dt);
    if (isMounted &&                                       // Modify from here...
        world.children.whereType<Player>().isEmpty &&
        world.children.whereType<Enemy>().isNotEmpty) {
      addPlayer();
    }
    if (isMounted &&
        enemiesFullyAdded &&
        world.children.whereType<Enemy>().isEmpty &&
        world.children.whereType<TextComponent>().isEmpty) {
      world.addAll(
        [
          (position: Vector2(0.5, 0.5), color: Colors.white),
          (position: Vector2.zero(), color: Colors.orangeAccent),
        ].map(
          (e) => TextComponent(
            text: 'You win!',
            anchor: Anchor.center,
            position: e.position,
            textRenderer: TextPaint(
              style: TextStyle(color: e.color, fontSize: 16),
            ),
          ),
        ),
      );
    }
  }

  var enemiesFullyAdded = false;

  Future<void> addEnemies() async {
    await Future<void>.delayed(const Duration(seconds: 2));
    for (var i = 0; i < 3; i++) {
      await world.add(
        Enemy(
          Vector2(
              camera.visibleWorldRect.right / 3 +
                  (_random.nextDouble() * 7 - 3.5),
              (_random.nextDouble() * 3)),
          aliens.getSprite(EnemyColor.randomColor.fileName),
        ),
      );
      await Future<void>.delayed(const Duration(seconds: 1));
    }
    enemiesFullyAdded = true;                              // To here.
  }
}

이 챌린지를 수락하기로 한 경우, 게임을 실행하고 이 화면으로 이동해야 합니다.

배경에 초록색 언덕, 지면 레이어, 지면의 블록, &#39;Youwin!&#39;이라는 텍스트 오버레이가 있는 앱 창

8. 축하합니다

축하합니다. Flutter와 Flame을 사용하여 게임을 빌드하는 데 성공했습니다.

Flame 2D 게임 엔진을 사용하여 게임을 빌드하고 Flutter 래퍼에 삽입했습니다. Flame의 효과를 사용하여 구성요소에 애니메이션을 적용하고 삭제했습니다. Google Fonts와 Flutter Animate 패키지를 사용하여 전체 게임이 잘 디자인된 것처럼 보이게 했습니다.

다음 단계

다음 Codelab을 확인하세요.

추가 자료