Flutter ve Flame ile 2D fizik oyunu geliştirin

1. Başlamadan önce

Flame, Flutter tabanlı bir 2D oyun motorudur. Bu codelab'de, Box2D'deki Forge2D adıyla benzer 2D fizik simülasyonu kullanan bir oyun geliştireceksiniz. Flame'in bileşenlerini kullanarak fiziksel gerçekliğin simülasyonunu gerçekleştiriyorsunuz. Böylece kullanıcılar oyun oynayabilir. Tamamlandığında, oyununuz şu animasyonlu GIF şeklinde görünmelidir:

Bu 2D fizik oyunuyla oyunun animasyonu

Ön koşullar

Öğrenecekleriniz

  • Farklı fiziksel vücut türlerinden başlayarak Forge2D'nin temellerinin işleyiş şekli.
  • 2D fiziksel simülasyon nasıl ayarlanır?

İhtiyacınız olanlar

Seçtiğiniz geliştirme hedefi için derleyici yazılımı. Bu codelab, Flutter'ın desteklediği altı platformun tamamında çalışır. Windows'u hedeflemek için Visual Studio, macOS veya iOS'i hedeflemek için Xcode, Android'i hedeflemek için Android Studio'ya ihtiyacınız vardır.

2. Proje oluşturma

Flutter projenizi oluşturun

Flutter projesi oluşturmanın birçok yolu vardır. Bu bölümde, komut satırını kısaltmak için kullanacaksınız.

Başlamak için aşağıdaki adımları uygulayın:

  1. Komut satırında bir Flutter projesi oluşturun:
$ 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 ve Forge2D'yi eklemek için projenin bağımlılıklarını değiştirin:
$ 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 paketi size tanıdık gelecektir ancak diğer üç paketin açıklaması gerekebilir. characters paketi, UTF8'e uygun şekilde dosya yolunu değiştirmek için kullanılır. flame_forge2d paketi, Forge2D işlevini Flame ile uyumlu bir şekilde ortaya koyar. Son olarak xml paketi, XML içeriğini kullanmak ve değiştirmek için çeşitli yerlerde kullanılır.

Projeyi açın ve lib/main.dart dosyasının içeriğini aşağıdaki kodla değiştirin:

lib/main.dart

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

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

Bu işlem uygulamayı, FlameGame örneğini örnekleyen bir GameWidget ile başlatır. Bu codelab'de, çalışan oyun hakkındaki bilgileri görüntülemek için oyun örneğinin durumunu kullanan bir Flutter kodu yoktur. Bu nedenle, bu basitleştirilmiş önyükleme sorunsuz bir şekilde çalışır.

İsteğe bağlı: macOS'e özel yan bir göreve katılın

Bu projedeki ekran görüntüleri, macOS masaüstü uygulaması olan oyundan alınmıştır. Uygulamanın başlık çubuğunun genel deneyimi olumsuz etkilemesini önlemek için macOS çalıştırıcısının proje yapılandırmasını, başlık çubuğunu kaydıracak şekilde değiştirebilirsiniz.

Bunun için, aşağıdaki adımları uygulayın:

  1. Bir bin/modify_macos_config.dart dosyası oluşturun ve şu içeriği ekleyin:

bin/modify_macos_config.dart ile değiştirin

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());
}

Bu dosya, oyunun çalışma zamanı kod tabanının bir parçası olmadığı için lib dizininde değil. Projede değişiklik yapmak için kullanılan bir komut satırı aracıdır.

  1. Proje temel dizininden aracı aşağıdaki şekilde çalıştırın:
$ dart bin/modify_macos_config.dart

Her şey planlandığı gibi giderse program, komut satırında herhangi bir çıkış oluşturmaz. Bununla birlikte, macos/Runner/Base.lproj/MainMenu.xib yapılandırma dosyasını oyunu görünür bir başlık çubuğu olmadan ve tüm pencereyi kaplayacak şekilde Flame oyunu kaplayacak şekilde çalıştırmak için değiştirir.

Her şeyin çalıştığını doğrulamak için oyunu çalıştırın. Yalnızca boş siyah arka planı olan yeni bir pencere görüntülemelidir.

Arka planı siyah olan ve ön planında hiçbir şey bulunmayan uygulama penceresi

3. Resim öğeleri ekleme

Resimleri ekleyin

Bir ekranı eğlence bulmayı kullanacak şekilde boyayabilmek için tüm oyunların sanat öğeleri içermesi gerekir. Bu codelab'de Kenney.nl adresindeki Fizik Öğeleri paketi kullanılır. Bu öğeler Creative Commons CC0 lisansına sahip olsa da ortaya koydukları mükemmel çalışmaya devam edebilmeleri için Kenney'deki ekibe bağışta bulunmanızı kesinlikle tavsiye ediyorum. Yaptım.

Kenney'nin öğelerinin kullanımını etkinleştirmek için pubspec.yaml yapılandırma dosyasını değiştirmeniz gerekir. Şu şekilde değiştirin:

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, resim öğelerinin farklı şekilde yapılandırılabilir olsa da assets/images konumunda bulunmasını bekliyor. Daha ayrıntılı bilgi için Flame'in Görseller belgelerine bakın. Yolları yapılandırdığınıza göre artık projenin kendisine eklemeniz gerekir. Bunu yapmanın bir yolu komut satırını aşağıdaki şekilde kullanmaktır:

$ mkdir -p assets/images

mkdir komutundan çıkış olmamalıdır, ancak yeni dizin düzenleyicinizde veya dosya gezgininde görünür olmalıdır.

İndirdiğiniz kenney_physics-assets.zip dosyasını genişlettiğinizde şuna benzer bir sonuç görürsünüz:

PNG/Arka Plan dizininin vurgulandığı kenney_physics-assets paketinin dosya listesi genişletildi.

PNG/Backgrounds dizininden colored_desert.png, colored_grass.png, colored_land.png ve colored_shroom.png dosyalarını projenizin assets/images dizinine kopyalayın.

Model sayfaları da mevcuttur. Bu dosyalar, bir PNG resmi ile daha küçük resimlerin model sayfası resminin nerede bulunabileceğini açıklayan bir XML dosyasının birleşimidir. Model sayfaları, yüzlerce, hatta yüzlerce ayrı resim dosyası yerine yalnızca tek bir dosya yükleyerek yükleme süresini azaltmaya yönelik bir tekniktir.

kenney_physics-assets paketinin dosya listesi genişletildi ve Spritesheet dizini vurgulanıyor

spritesheet_aliens.png, spritesheet_elements.png ve spritesheet_tiles.png alanlarını projenizin assets/images dizinine kopyalayın. Bu sayfada ayrıca spritesheet_aliens.xml, spritesheet_elements.xml ve spritesheet_tiles.xml dosyalarını projenizin assets dizinine kopyalayın. Projeniz aşağıdaki gibi görünmelidir.

forge2d_game proje dizininin öğe dizininin vurgulandığı dosya listesi

Arka planı boya

Artık projenize resim öğeleri eklendiğine göre bunları ekrana koymanın zamanı geldi. Ekranda bir resim var. Sonraki adımlarda daha fazla özellik eklenecektir.

lib/components adlı yeni dizinde background.dart adlı bir dosya oluşturun ve aşağıdaki içeriği ekleyin.

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,
    ));
  }
}

Bu bileşen, özel bir SpriteComponent öğesidir. Kenney.nl'nin dört arka plan resminden birini görüntülemekten sorumludur. Bu kodda basitleştirici birkaç varsayım vardır. İlk olarak, resimler kare şeklinde ve Kerem'in arka plan resimlerinin dördü de kare şeklinde. İkincisi ise görünür dünyanın boyutunun hiçbir zaman değişmemesidir. Aksi takdirde, bu bileşenin oyun yeniden boyutlandırma etkinliklerini yönetmesi gerekir. Üçüncü varsayım, konumun (0,0) ekranın ortasında olacağıdır. Bu varsayımlar için oyuna ait CameraComponent öğesinin belirli bir şekilde yapılandırılması gerekir.

lib/components dizininde tekrar game.dart adında başka bir dosya oluşturun.

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();
  }
}

Burada çok şey oluyor. MyPhysicsGame sınıfıyla başlayalım. Önceki codelab'den farklı olarak bu, FlameGame yerine Forge2DGame öğesini kapsar. Forge2DGame, birkaç ilginç değişiklikle FlameGame alanını genişletiyor. Birincisi, zoom politikasının varsayılan olarak 10 değerine ayarlanmasıdır. Bu zoom ayarı, Box2D stili fizik simülasyonu motorlarının iyi çalıştığı yararlı değer aralığıyla ilgilidir. Motor, MKS sistemi kullanılarak yazılmıştır. Bu sistemde birimlerin metre, kilogram ve saniye cinsinden olduğu varsayılmıştır. Nesneler için fark edilebilir matematik hataları görmediğiniz aralık 0,1 metre ile 10 saniye arasında değişir. Belirli bir aşağı ölçeklendirme düzeyi olmadan doğrudan piksel boyutlarında feed besleme yapmak, Forge2D'yi kullanışlı sınırlarının dışına çıkarır. Buradaki özetin faydası, sodanın otobüse kadar uzandığı mesafedeki nesnelerin simüle edilmesidir.

CameraComponent öğesinin çözünürlüğü 800x600 sanal piksel olarak düzeltilerek, Arka Plan bileşeninde yapılan varsayımlar yerine getirildi. Bu, oyun alanının (0,0) merkezli şekilde 80 birim genişliğinde ve 60 birim yüksekliğinde olacağı anlamına gelir. Bu seçim, görüntülenen çözünürlüğü etkilemez ancak nesneleri oyun sahnesinde yerleştirdiğimiz yeri etkiler.

camera oluşturucu bağımsız değişkeninin yanında, gravity adında fizik kurallarına uygun başka bir bağımsız değişken daha vardır. Yer çekimi x değeri 0 ve y değeri 10 olacak şekilde Vector2 olarak ayarlandı. 10, genellikle kabul edilen 9,81 metre/saniye yer çekimi değerine yakın bir değerdir. Yer çekiminin pozitif 10'a ayarlanması, bu sistemde Y ekseninin yönünün aşağı doğru olduğunu gösterir. Genel olarak Box2D'den farklıdır ancak Flame'in genellikle yapılandırılma şekliyle uyumludur.

Sırada onLoad yöntemi var. Bu yöntem eşzamansızdır ve diskten resim öğelerinin yüklenmesinden sorumlu olduğu için uygundur. images.load çağrıları, bir Future<Image> döndürür ve yan efekt olarak, yüklenen resim Oyun nesnesinde önbelleğe alınır. Bu gelecekler bir araya toplanır ve Futures.wait statik yöntemi kullanılarak tek bir birim olarak beklenmektedir. Döndürülen görsellerin listesi daha sonra kalıp, tek tek adlarla eşleştirilir.

Daha sonra model sayfası resimleri bir dizi XmlSpriteSheet nesneye beslenir ve bu nesneler, model sayfasında yer alan bağımsız olarak adlandırılmış Sprite öğeleri almaktan sorumlu olur. XmlSpriteSheet sınıfı flame_kenney_xml paketinde tanımlanmıştır.

Tüm bunların yanı sıra, ekranda bir görselin yer alması için lib/main.dart ürününde yalnızca birkaç küçük düzenleme yapmanız yeterli olacaktır.

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
    ),
  );
}

Bu basit değişiklikle artık oyunu tekrar çalıştırabilir ve ekranda arka planı görebilirsiniz. Oyunun 800x600 oranını çalışması için CameraComponent.withFixedResolution() kamera örneğinin sinemaskop efekti ekleyeceğini unutmayın.

İnişli çıkışlı yeşil tepelerin ve garip bir şekilde soyut ağaçların yer aldığı arka plan resminin yer aldığı uygulama penceresi.

4. Zemin ekleyin

Üzerine inşa edilecek bir şey

Yer çekimi varsa oyunda nesneleri ekranın altından düşmeden önce yakalamak için bir şeye ihtiyacımız vardır. Ekrandan çıkmak oyun tasarımınızın bir parçası değilse elbette. lib/components dizininizde yeni bir ground.dart dosyası oluşturun ve aşağıdakini bu dosyaya ekleyin:

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),
            ),
          ],
        );
}

Bu Ground bileşeni, BodyComponent kaynağından türetilir. Forge2D'de cisimler önemlidir. Bunlar, iki boyutlu fiziksel simülasyonun parçası olan nesnelerdir. Bu bileşen için BodyDef değeri, BodyType.static olarak belirtilmiş.

Forge2D'de vücutların üç farklı türü vardır. Statik cisimler hareket etmez. Bu cisimlerin hem kütlesi sıfırdır hem de yer çekimine tepki vermez ve sonsuz kütleye sahiptir. Ne kadar ağır olursa olsun diğer cisimler çarptığında hareket etmez. Bu, statik cisimler hareket etmediğinden zemin yüzeyi için mükemmeldir.

Diğer iki cisim türü kinematik ve dinamiktir. Dinamik cisimler tamamen simüle edilmiş vücutlardır. Yer çekimine ve çarpıştıkları cisimlere tepki verirler. Bu codelab'in geri kalanında çok sayıda dinamik gövde göreceksiniz. Kinematik cisimler statik ve dinamik arasında bir evin ortasındadır. Hareket ederler ancak yer çekimine veya diğer nesnelere çarpmasına tepki vermezler. Faydalı ancak bu codelab'in kapsamı dışındadır.

Vücudun kendisi fazla bir şey yapmaz. Vücudun özdeş olması için birbiriyle ilişkili şekillere ihtiyacı vardır. Bu durumda, bu gövdenin ilişkilendirilmiş bir şekli vardır ve PolygonShape, BoxXY olarak ayarlanmıştır. Bu kutu türü, bir dönme noktası etrafında döndürülebilen BoxXY olarak ayarlanmış PolygonShape öğelerinin aksine, dünyayla hizalı bir eksendir. Yine faydalıdır ancak bu codelab'in kapsamı dışındadır. Şekil ve gövde bir armatürle birbirine bağlanır. Bu, sisteme friction gibi öğelerin eklenmesinde kullanışlıdır.

Gövde, varsayılan olarak ekli şekilleri hata ayıklama için faydalı olacak, ancak oynanabilirlik açısından faydalı olmayacak şekilde oluşturur. super bağımsız değişkeni renderBody false olarak ayarlandığında bu hata ayıklama oluşturma işlemi devre dışı bırakılır. Bu gövdeye oyun içi oluşturma işlemi verilmesi, SpriteComponent adlı çocuğun sorumluluğundadır.

Ground bileşenini oyuna eklemek için game.dart dosyanızı aşağıdaki gibi düzenleyin.

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.
}

Bu düzenleme, List bağlamı içinde bir for döngüsü kullanarak ve elde edilen Ground bileşen listesini world öğesinin addAll yöntemine ileterek bir dizi Ground bileşeni ekler.

Oyun çalıştırıldığında artık arka plan ve zemin gösteriliyor.

Arka planı ve zemin katmanını gösteren uygulama penceresi.

5. Tuğlaları ekleyin

Duvar inşa etme

Zemin, bize statik bir gövde örneği gösterdi. Şimdi ilk dinamik bileşeninizin zamanı geldi. Forge2D'deki dinamik bileşenler, oyuncunun deneyiminin temel taşı olup etrafındaki dünyayı hareket ettiren ve onunla etkileşime giren şeylerdir. Bu adımda, ekranda bir grup tuğla halinde görünecek şekilde rastgele seçilecek tuğlaları yerleştireceksiniz. Bunu yaparken birbirlerine düşüp çarpıştıklarını göreceksiniz.

Tuğlalar, öğelerin model sayfasından oluşturulacak. assets/spritesheet_elements.xml içindeki model sayfası açıklamasına bakarsanız ilginç bir sorunumuz olduğunu göreceksiniz. Adlar pek faydalı görünmüyor. Bir tuğlayı malzeme türüne, boyutuna ve hasar miktarına göre seçmek faydalı olacaktır. Neyse ki yardımsever bir elf dosya adlandırma kalıbını bulmak için biraz vakit geçirdi ve hepinize kolaylık sağlayacak bir araç geliştirdi. bin dizininde generate_brick_file_names.dart adlı yeni bir dosya oluşturun ve aşağıdaki içeriği ekleyin:

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();
}

Düzenleyiciniz, eksik bir bağımlılık hakkında uyarı veya hata gösterecektir. Aşağıdaki gibi ekleyin:

$ flutter pub add equatable

Artık bu programı aşağıdaki şekilde çalıştırabiliyor olmanız gerekir:

$ 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',
      },
  };
}

Bu araç, model sayfası açıklama dosyasını faydalı bir şekilde ayrıştırdı ve ekrana yerleştirmek istediğiniz her bir tuğla için doğru resim dosyasını seçmek üzere kullanabileceğimiz Dart koduna dönüştürdü. Yararlı!

Aşağıdaki içeriğe sahip brick.dart dosyasını oluşturun:

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();
  }
}

Tuğla resimleri malzeme, boyut ve duruma göre hızlı ve kolay bir şekilde seçmek için, yukarıda oluşturulan Dart kodunun bu kod tabanına nasıl entegre edildiğini görebilirsiniz. enum öğelerini geçip Brick bileşeninin kendisine baktığınızda, bu kodun büyük kısmının önceki adımdaki Ground bileşenine göre oldukça tanıdık geldiğini görürsünüz. Burada, tuğlanın hasar görmesine izin verecek değişebilen bir durum mevcuttur, ancak bunu okuyucu için bir alıştırma olarak bırakıyoruz.

Tuğlaları ekrana geçirme vakti. game.dart dosyasını şu şekilde düzenleyin:

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.
}

Bu kod ekleme işlemi, Ground bileşenlerini eklemek için kullandığınız koddan biraz farklıdır. Bu kez Brick öğeleri zaman içinde rastgele bir kümeye ekleniyor. Bunun iki bölümü vardır. İlki, Brick await öğesini bir Future.delayed ekleme yöntemidir. Bu yöntem, sleep() çağrısının eşzamansız eşdeğeridir. Ancak, bunun çalışmasını sağlamanın ikinci bir kısmı daha vardır. onLoad yönteminde addBricks çağrısı await yapılmaz. Yayınlanıyor olsaydı, tüm tuğlalar ekranda görünene kadar onLoad yöntemi tamamlanmazdı. addBricks ile ilgili unawaited görüşmesinin tamamlanması, test uzmanlarını mutlu ediyor ve amacımızın gelecekteki programcılar için açık olmasını sağlıyor. Bu yöntemin geri gelmesini beklemek bilerek yapılır.

Oyunu çalıştırdığınızda tuğlaların belirdiğini, birbirine çarptığını ve yere döküldüğünü göreceksiniz.

Arka planda yeşil tepelerin, yer katmanının ve yere inen blokların bulunduğu bir uygulama penceresi.

6. Oyuncuyu ekle

Uzaylıları tuğlalara fırlatıyor

İlk birkaçda tuğlaların düşmesini izlemek eğlencelidir, bence oyuncuya dünyayla etkileşim kurmak için kullanabileceği bir avatar verirsek bu oyun daha eğlenceli olacak. Tuğlalara doğru hızla fırlatabilecekleri bir uzaylıya ne dersiniz?

lib/components dizininde yeni bir player.dart dosyası oluşturun ve aşağıdakini bu dosyaya ekleyin:

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;
}

Bu, önceki adımda yer alan Brick bileşenlerinin bir adımıdır. Bu Player bileşeninin, tanımanız gereken SpriteComponent ve yeni CustomPainterComponent olmak üzere iki alt bileşeni vardır. Flutter'ın geliştirdiği CustomPainter konsepti sayesinde tuvale resim yapabilirsiniz. Bu bilgi, oyuncuya yuvarlak uzaylının fırlatıldığında nereye uçacağı hakkında geri bildirim vermek için kullanılır.

Oyuncu, uzaylıyla saldırıyı nasıl başlatır? Oynatıcı bileşeninin, DragCallbacks geri çağırma özelliğiyle algıladığı bir sürükleme hareketi kullanılır. Aranızdaki kartallar burada başka bir şey fark etmiş olacak.

Ground bileşenlerinin statik cisimler olduğu durumlarda, tuğla bileşenleri dinamik cisimlerdi. Buradaki oynatıcı, ikisinin birleşimidir. Oyuncu hareketsiz olarak başlar, oyuncunun öğeyi sürüklemesini bekler. Sürükleme durumunda kendini statikten dinamike dönüştürür, sürüklemeyle orantılı olarak doğrusal bir itme ekler ve uzaylı avatarının uçmasını sağlar.

Ayrıca Player bileşeninde, sınır dışına çıktığında, uykuya daldığında veya zaman aşımına uğradığında ekrandan kaldırılacağı kod da bulunur. Buradaki amaç oyuncunun uzaylıyı savurup ne olduğunu görmesine ve bir kez daha denemesine izin vermektir.

game.dart öğesini aşağıdaki gibi düzenleyerek Player bileşenini oyuna entegre edin:

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.
}

Oyuncuyu oyuna eklemek önceki bileşenlere benzer ancak fazladan bir kırışıklık daha içeriyor. Oyuncunun uzaylısı, belirli koşullar altında kendini oyundan kaldıracak şekilde tasarlanmıştır. Dolayısıyla burada, oyunda Player bileşeni olup olmadığını kontrol eden ve varsa bu bileşeni tekrar ekleyen bir güncelleme işleyici vardır. Oyun şuna benzer şekilde çalıştırılabilir.

Arka planda yeşil tepeler bulunan uygulama penceresi, yer katmanı, yerde bloklar ve uçan oyuncu avatarı.

7. Etkilere tepki verin

Düşmanları ekleme

Birbiriyle etkileşime giren statik ve dinamik nesneler gördünüz. Ancak gerçekten bir yere varmak için işler çakıştığında kodda geri çağırmalar almanız gerekir. Şimdi bunun nasıl yapıldığına bakalım. Oyuncunun mücadele edeceği bazı düşmanlarla karşılaşacaksınız. Bu şekilde, durumu kazanan bir koşul haline getirirsiniz. Tüm düşmanları oyundan çıkarın.

lib/components dizininde bir enemy.dart dosyası oluşturun ve aşağıdakini ekleyin:

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();
}

Oynatıcı ve Tuğla bileşenleriyle önceki etkileşimlerinizden bu dosyanın büyük bir kısmı tanıdık gelecektir. Ancak bilinmeyen yeni bir temel sınıf nedeniyle düzenleyicinizde birkaç kırmızı alt çizgi olacak. lib/components klasörüne aşağıdaki içeriğe sahip body_component_with_user_data.dart adlı bir dosya ekleyerek bu sınıfı hemen ekleyin:

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 bileşenindeki yeni beginContact geri çağırma özelliğiyle birlikte bu temel sınıf, vücutlar arasındaki etkiler hakkında programatik olarak bildirim almanın temelini oluşturur. Aslında, aralarında etki bildirimleri almak istediğiniz tüm bileşenleri düzenlemeniz gerekir. Bu nedenle Brick, Ground ve Player bileşenlerini düzenleyerek bu bileşenlerin şu anda kullandığı BodyComponent temel sınıfın yerine bu BodyComponentWithUserData bileşenini kullanacak şekilde düzenleyin. Örneğin, Ground bileşenini nasıl düzenleyeceğiniz aşağıda açıklanmıştır:

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'nin kişileri nasıl işlediği hakkında daha fazla bilgi edinmek için lütfen kişi geri çağırmalarıyla ilgili Forge2D dokümanlarına göz atın.

Oyunu kazanma

Artık düşmanlarınız olduğuna ve düşmanları dünyadan imha ettiğinize göre bu simülasyonu oyuna dönüştürmenin basit bir yolu var. Hedefte tüm düşmanları yok edin! game.dart dosyasını şu şekilde düzenlemenin zamanı geldi:

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.
  }
}

Kabul ederseniz meydan okumanız gereken adım oyunu yönetmek ve bu ekrana ulaşmaktır.

Arka planda yeşil tepeler, yer katmanı, yerde bloklar ve üzerinde &quot;Kazandınız!&quot; yazısı bulunan bir uygulama penceresi

8. Tebrikler

Tebrikler, Flutter ve Flame ile başarılı bir oyun geliştirdiniz.

Flame 2D oyun motorunu kullanarak bir oyun geliştirdiniz ve bu oyunu Flutter sarmalayıcıya yerleştirdiniz. Bileşenleri canlandırmak ve kaldırmak için Flame'in Efektlerini kullandınız. Oyunun tamamının iyi tasarlanmış görünmesini sağlamak için Google Fonts ve Flutter Animate paketlerini kullandınız.

Sırada ne var?

Bu codelab'lerden bazılarına göz atın...

Daha fazla bilgi