Membangun game fisika 2D dengan Flutter dan Flame

1. Sebelum memulai

Flame adalah game engine 2D berbasis Flutter. Dalam codelab ini, Anda akan membangun game yang menggunakan simulasi fisika 2D sepanjang baris Box2D yang disebut Forge2D. Anda menggunakan komponen Flame untuk melukiskan simulasi realitas fisik di layar untuk dimainkan oleh pengguna Anda. Setelah selesai, game Anda akan terlihat seperti gif animasi ini:

Animasi gameplay dengan game fisika 2D ini

Prasyarat

Yang Anda pelajari

  • Bagaimana dasar-dasar Forge2D bekerja, dimulai dengan berbagai jenis tubuh fisik.
  • Cara menyiapkan simulasi fisik dalam 2D.

Yang Anda perlukan

Software compiler untuk target pengembangan pilihan Anda. Codelab ini berfungsi untuk keenam platform yang didukung Flutter. Anda memerlukan Visual Studio untuk menargetkan Windows, Xcode untuk menargetkan macOS atau iOS, dan Android Studio untuk menargetkan Android.

2. Membuat project

Membuat project Flutter Anda

Ada banyak cara untuk membuat project Flutter. Di bagian ini, Anda akan menggunakan command line agar lebih singkat.

Untuk memulai, ikuti langkah-langkah berikut:

  1. Pada command line, buat project 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. Ubah dependensi project untuk menambahkan Flame dan 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.

Paket flame tidak asing bagi Anda, tetapi tiga paket lainnya mungkin memerlukan penjelasan. Paket characters digunakan untuk manipulasi jalur file dengan cara yang sesuai UTF8. Paket flame_forge2d mengekspos fungsi Forge2D sedemikian rupa sehingga berfungsi baik dengan Flame. Terakhir, paket xml digunakan di berbagai tempat untuk menggunakan dan memodifikasi konten XML.

Buka project, lalu ganti konten file lib/main.dart dengan berikut ini:

lib/main.dart

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

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

Tindakan ini akan memulai aplikasi dengan GameWidget yang membuat instance FlameGame. Tidak ada kode Flutter dalam codelab ini yang menggunakan status instance game untuk menampilkan informasi tentang game yang sedang berjalan, sehingga bootstrap yang disederhanakan ini berfungsi dengan baik.

Opsional: Lakukan quest samping khusus macOS

Screenshot dalam project ini berasal dari game sebagai aplikasi desktop macOS. Agar kolom judul aplikasi tidak mengurangi pengalaman secara keseluruhan, Anda dapat mengubah konfigurasi project runner macOS agar kolom judul menjadi sama.

Untuk melakukannya, ikuti langkah-langkah berikut:

  1. Buat file bin/modify_macos_config.dart dan tambahkan konten berikut:

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

File ini tidak ada di direktori lib karena bukan bagian dari codebase runtime untuk game. Ini adalah alat baris perintah yang digunakan untuk memodifikasi proyek.

  1. Dari direktori berbasis project, jalankan alat sebagai berikut:
$ dart bin/modify_macos_config.dart

Jika semuanya berjalan sesuai rencana, program tidak akan menghasilkan output pada command line. Namun, game ini akan mengubah file konfigurasi macos/Runner/Base.lproj/MainMenu.xib untuk menjalankan game tanpa panel judul yang terlihat dan dengan game Flame yang menggunakan seluruh jendela.

Jalankan game untuk memverifikasi bahwa semuanya berfungsi. Seharusnya jendela baru yang ditampilkan hanya dengan latar belakang hitam kosong.

Jendela aplikasi dengan latar belakang hitam dan tidak ada apa pun di latar depan

3. Tambahkan aset gambar

Tambahkan gambar

Game apa pun membutuhkan aset seni untuk dapat melukis layar dengan cara yang menyenangkan. Codelab ini akan menggunakan paket Physics Assets dari Kenney.nl. Aset ini berlisensi Creative Commons CC0, tetapi kami sangat menyarankan Anda untuk berdonasi kepada tim di Kenney agar mereka dapat terus berkarya. Ya.

Anda perlu mengubah file konfigurasi pubspec.yaml untuk mengaktifkan penggunaan aset Kenney. Ubah seperti berikut:

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 mengharapkan aset gambar ditempatkan di assets/images, meskipun hal ini dapat dikonfigurasi secara berbeda. Lihat dokumentasi Gambar Flame untuk detail selengkapnya. Setelah jalur dikonfigurasi, Anda harus menambahkannya ke project itu sendiri. Salah satu cara untuk melakukannya adalah dengan menggunakan command line sebagai berikut:

$ mkdir -p assets/images

Seharusnya tidak ada output dari perintah mkdir, tetapi direktori baru harus terlihat di editor atau file explorer.

Luaskan file kenney_physics-assets.zip yang Anda download, dan Anda akan melihat sesuatu seperti ini:

Daftar file paket kenney_ physical-assets diperluas, dengan direktori PNG/Backgrounds ditandai

Dari direktori PNG/Backgrounds, salin di seluruh file colored_desert.png, colored_grass.png, colored_land.png, dan colored_shroom.png ke direktori assets/images project Anda.

Ada juga sprite sheet. Ini adalah kombinasi gambar PNG dan file XML yang menjelaskan di mana gambar yang lebih kecil dapat ditemukan dalam gambar spritesheet. Spritesheet adalah teknik untuk mengurangi waktu pemuatan dengan hanya memuat satu file, bukan puluhan, atau ratusan, file gambar individual.

Daftar file paket {i>kenney_physics-assets<i} diperluas, dengan direktori Spritesheet ditandai

Salin di seluruh spritesheet_aliens.png, spritesheet_elements.png, dan spritesheet_tiles.png ke direktori assets/images project Anda. Selagi Anda berada di sini, salin juga file spritesheet_aliens.xml, spritesheet_elements.xml, dan spritesheet_tiles.xml ke direktori assets project Anda. Project Anda akan terlihat seperti berikut.

Listingan file direktori project forge2d_game, dengan direktori aset ditandai

Lukis latar belakang

Setelah project Anda menambahkan aset gambar, saatnya untuk menampilkannya di layar. Nah, satu gambar di layar. Info selengkapnya akan dijelaskan dalam langkah-langkah berikut.

Buat file bernama background.dart di direktori baru bernama lib/components, lalu tambahkan konten berikut.

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

Komponen ini adalah SpriteComponent khusus. Anda bertanggung jawab untuk menampilkan salah satu dari empat gambar latar Kenney.nl. Ada beberapa asumsi yang menyederhanakan dalam kode ini. Yang pertama adalah bahwa gambarnya berbentuk persegi, di mana keempat gambar latar dari Kenney berada. Yang kedua adalah ukuran dunia yang terlihat tidak akan pernah berubah, jika tidak, komponen ini harus menangani peristiwa pengubahan ukuran game. Asumsi ketiga adalah posisi (0,0) akan berada di tengah layar. Asumsi ini memerlukan konfigurasi khusus dari CameraComponent game.

Buat file baru lain, yang bernama game.dart, lagi di direktori lib/components.

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

Banyak yang terjadi di sini. Mari kita mulai dengan class MyPhysicsGame. Tidak seperti codelab sebelumnya, codelab ini memperluas Forge2DGame, bukan FlameGame. Forge2DGame sendiri memperluas FlameGame dengan beberapa penyesuaian menarik. Yang pertama adalah, secara default, zoom disetel ke 10. Setelan zoom ini berkaitan dengan rentang nilai berguna yang dapat digunakan dengan baik oleh mesin simulasi fisika gaya Box2D. Mesin ini ditulis menggunakan sistem MKS yang diasumsikan dalam meter, kilogram, dan detik. Rentang di mana Anda tidak melihat kesalahan matematika yang jelas untuk objek adalah dari 0,1 meter hingga 10 detik meter. Memberi makan dalam dimensi piksel secara langsung tanpa beberapa tingkat penskalaan bawah akan membuat Forge2D keluar dari cakupan kegunaannya. Ringkasan yang berguna adalah melakukan simulasi objek dalam kisaran kaleng soda hingga sebuah bus.

Asumsi yang dibuat dalam komponen Latar Belakang dipenuhi di sini dengan memperbaiki resolusi CameraComponent menjadi 800x600 piksel virtual. Artinya area permainan akan memiliki lebar 80 unit, dan tinggi 60 unit, berpusat pada (0,0). Tindakan ini tidak berpengaruh pada resolusi yang ditampilkan, tetapi akan memengaruhi posisi objek dalam scene game.

Bersamaan dengan argumen konstruktor camera, terdapat argumen lain yang lebih selaras dengan fisika yang disebut gravity. Gravitasi disetel ke Vector2 dengan x 0 dan y 10. Angka 10 adalah perkiraan dekat dari nilai gravitasi 9,81 meter per detik per detik yang diterima secara umum. Fakta bahwa gravitasi ditetapkan ke positif 10 menunjukkan bahwa dalam sistem ini arah sumbu Y turun. Berbeda dengan Box2D pada umumnya, tetapi sesuai dengan cara Flame dikonfigurasi biasanya.

Selanjutnya adalah metode onLoad. Metode ini asinkron, yang sesuai karena bertanggung jawab untuk memuat aset gambar dari disk. Panggilan ke images.load menampilkan Future<Image>, dan sebagai efek samping, gambar yang dimuat akan di-cache di objek Game. Future ini dikumpulkan dan ditunggu sebagai satu unit menggunakan metode statis Futures.wait. Daftar gambar yang dikembalikan kemudian dicocokkan dengan nama masing-masing.

Gambar spritesheet kemudian dimasukkan ke dalam serangkaian objek XmlSpriteSheet yang bertanggung jawab untuk mengambil Sprite yang dinamai secara individual yang terdapat dalam spritesheet. Class XmlSpriteSheet ditentukan dalam paket flame_kenney_xml.

Setelah itu, Anda hanya perlu melakukan beberapa pengeditan kecil pada lib/main.dart untuk menampilkan gambar di layar.

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

Dengan perubahan sederhana ini, Anda kini dapat menjalankan game lagi untuk melihat latar belakang di layar. Perhatikan bahwa instance kamera CameraComponent.withFixedResolution() akan menambahkan tampilan lebar seperti yang diperlukan agar game memiliki rasio 800 x 600.

Jendela aplikasi dengan gambar latar perbukitan hijau dan pohon abstrak yang aneh.

4. Tambahkan tanah

Sesuatu untuk dikembangkan

Jika memiliki gravitasi, kita memerlukan sesuatu untuk menangkap objek dalam game sebelum jatuh dari bagian bawah layar. Tentu saja, kecuali jika jatuh dari layar adalah bagian dari desain game Anda. Buat file ground.dart baru di direktori lib/components Anda dan tambahkan kode berikut ke dalamnya:

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

Komponen Ground ini berasal dari BodyComponent. Dalam Forge2D benda penting, mereka adalah objek yang merupakan bagian dari simulasi fisik dua dimensi. BodyDef untuk komponen ini ditentukan agar memiliki BodyType.static.

Dalam Forge2D, badan memiliki tiga jenis yang berbeda. Benda statis tidak bergerak. Benda ini pada dasarnya memiliki massa nol - tidak bereaksi terhadap gravitasi - dan massa tak terbatas - tidak bergerak saat terbentur oleh benda lain, tidak peduli seberapa beratnya. Hal ini membuat benda statis sempurna untuk permukaan tanah, karena tidak bergerak.

Dua jenis badan lainnya adalah kinematik dan dinamis. Tubuh dinamis adalah benda yang sepenuhnya disimulasikan, serta bereaksi terhadap gravitasi dan terhadap objek yang menabraknya. Anda akan melihat banyak bagian dinamis dalam codelab ini. Benda kinematik adalah rumah setengah jalan antara statis dan dinamis. Benda bergerak, tetapi tidak bereaksi terhadap gravitasi atau benda lain yang menabraknya. Berguna, tetapi di luar cakupan codelab ini.

Tubuh itu sendiri tidak melakukan banyak hal. Tubuh membutuhkan bentuk terkait untuk memiliki zat. Dalam hal ini, isi ini memiliki satu bentuk terkait, PolygonShape yang ditetapkan sebagai BoxXY. Jenis kotak ini sejajar sumbu dengan dunia, tidak seperti PolygonShape yang ditetapkan sebagai BoxXY yang dapat diputar di sekitar titik rotasi. Sekali lagi berguna, tetapi juga di luar cakupan codelab ini. Bentuk dan bodi dipasang bersama dengan perlengkapan, yang berguna untuk menambahkan hal-hal seperti friction ke sistem.

Secara default, isi akan merender bentuk yang terpasang dengan cara yang berguna untuk proses debug, tetapi tidak cocok untuk gameplay yang bagus. Menyetel argumen super renderBody ke false akan menonaktifkan rendering debug ini. Memberikan rendering dalam game pada isi ini adalah tanggung jawab anak SpriteComponent.

Untuk menambahkan komponen Ground ke game, edit file game.dart Anda sebagai berikut.

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

Pengeditan ini menambahkan serangkaian komponen Ground ke dunia dengan menggunakan loop for di dalam konteks List, dan meneruskan daftar komponen Ground yang dihasilkan ke metode addAll world.

Menjalankan game sekarang menampilkan latar belakang dan tanah.

Jendela aplikasi dengan latar belakang dan lapisan tanah.

5. Tambahkan balok

Membuat dinding

Tanah memberi kita contoh benda statis. Sekarang saatnya untuk komponen dinamis pertama Anda. Komponen dinamis di Forge2D merupakan landasan dari pengalaman pemain, komponen dinamis adalah benda yang bergerak dan berinteraksi dengan dunia di sekitarnya. Pada langkah ini, Anda akan memperkenalkan batu bata, yang akan dipilih secara acak untuk muncul di layar dalam kelompok batu bata. Kamu akan melihat mereka jatuh dan bertabrakan dengan satu sama lain saat melakukannya.

Batu bata akan dibuat dari elemen sprite sheet. Jika Anda melihat deskripsi sprite sheet di assets/spritesheet_elements.xml, Anda akan melihat bahwa kita memiliki masalah yang menarik. Nama-nama tersebut sepertinya tidak terlalu membantu. Yang akan berguna adalah memilih batu bata berdasarkan jenis bahan, ukurannya, dan jumlah kerusakannya. Untungnya, seorang kurcaci yang membantu meluangkan waktu untuk mengetahui pola dalam penamaan file dan menciptakan alat yang memudahkan kalian semua. Buat file baru generate_brick_file_names.dart di direktori bin dan tambahkan konten berikut:

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

Editor Anda akan memberi peringatan atau error tentang dependensi yang tidak ada. Tambahkan sebagai berikut:

$ flutter pub add equatable

Sekarang Anda dapat menjalankan program ini seperti berikut:

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

Alat ini telah membantu menguraikan file deskripsi sprite sheet dan mengubahnya menjadi kode Dart yang dapat kita gunakan untuk memilih file gambar yang tepat untuk setiap batu bata yang ingin Anda tempatkan di layar. Bermanfaat!

Buat file brick.dart dengan konten berikut:

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

Sekarang Anda dapat melihat bagaimana kode Dart yang dihasilkan di atas diintegrasikan ke dalam codebase ini agar dapat memilih gambar bata berdasarkan bahan, ukuran, dan kondisi dengan cepat dan mudah. Dengan mencermati enum dan melihat komponen Brick itu sendiri, Anda akan mendapati bahwa sebagian besar kode ini sepertinya cukup familier dengan komponen Ground di langkah sebelumnya. Ada status yang dapat diubah di sini yang memungkinkan batu bata menjadi rusak, meskipun penggunaannya dibiarkan sebagai latihan bagi pembaca.

Saatnya menampilkan batu bata di layar. Edit file game.dart sebagai berikut:

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

Penambahan kode ini sedikit berbeda dengan kode yang Anda gunakan untuk menambahkan komponen Ground. Kali ini Brick ditambahkan dalam cluster acak, dari waktu ke waktu. Ada dua bagian untuk ini, yang pertama adalah metode yang menambahkan Brick await Future.delayed, yang setara dengan asinkron dari panggilan sleep(). Akan tetapi, ada bagian kedua agar cara ini berfungsi, panggilan ke addBricks dalam metode onLoad tidak awaitdilakukan. Jika ya, metode onLoad tidak akan selesai sampai semua batu bata berada di layar. Menggabungkan panggilan ke addBricks dalam panggilan unawaited akan membuat linter senang, dan membuat intent kita terlihat jelas bagi programmer berikutnya. Tidak menunggu metode ini ditampilkan adalah hal yang disengaja.

Jalankan game, dan kamu akan melihat batu bata muncul, menabrak satu sama lain, dan bertumpah ke tanah.

Jendela aplikasi dengan bukit hijau di latar belakang, lapisan tanah, dan blok yang mendarat di tanah.

6. Menambahkan pemain

Ayunkan alien ke batu bata

Menonton batu bata berjatuhan di awal memang menyenangkan, tapi saya rasa game ini akan lebih menyenangkan jika kita memberi pemain avatar yang bisa mereka gunakan untuk berinteraksi dengan dunia. Bagaimana dengan alien yang bisa mereka lemparkan ke batu bata?

Buat file player.dart baru di direktori lib/components, lalu tambahkan kode berikut ke dalamnya:

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

Ini merupakan langkah maju dari komponen Brick pada langkah sebelumnya. Komponen Player ini memiliki dua komponen turunan, SpriteComponent yang harus Anda kenali, dan CustomPainterComponent yang baru. Konsep CustomPainter berasal dari Flutter, dan memungkinkan Anda melukis di atas kanvas. Fungsi ini digunakan di sini untuk memberikan masukan kepada pemain tentang ke mana alien bulat akan terbang saat dilempar.

Bagaimana cara pemain memulai lemparan alien? Menggunakan gestur tarik, yang dideteksi komponen Pemain dengan callback DragCallbacks. Elang di antara kalian akan melihat sesuatu yang lain di sini.

Jika komponen Ground berupa badan statis, komponen Bata memiliki badan dinamis. Pemutar di sini adalah kombinasi dari keduanya. Pemain memulai dengan cara statis, menunggu pemain untuk menariknya, lalu saat menarik, pemutar mengubah dirinya sendiri dari statis menjadi dinamis, menambahkan impuls linear yang sesuai dengan tarikannya, dan membiarkan avatar alien terbang.

Ada juga kode dalam komponen Player untuk menghapusnya dari layar jika keluar dari batas, tertidur, atau kehabisan waktu. Tujuannya di sini adalah memungkinkan pemain melemparkan alien itu, melihat apa yang terjadi, dan kemudian mencoba lagi.

Integrasikan komponen Player ke dalam game dengan mengedit game.dart sebagai berikut:

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

Penambahan pemain ke game mirip dengan komponen sebelumnya, dengan satu kerut tambahan. Alien pemain didesain untuk menghapus dirinya sendiri dari game dalam kondisi tertentu, sehingga ada pengendali update di sini yang memeriksa apakah tidak ada komponen Player dalam game, dan jika ya, akan menambahkannya kembali. Menjalankan game akan terlihat seperti ini.

Jendela aplikasi dengan bukit hijau di latar belakang, lapisan tanah, blok di permukaan tanah, dan avatar pemain yang sedang terbang.

7. Bereaksi terhadap dampak

Menambahkan musuh

Anda telah melihat objek statis dan dinamis yang berinteraksi satu sama lain. Namun, untuk benar-benar mencapai suatu tempat, Anda perlu mendapatkan callback dalam kode saat terjadi tumpang tindih. Mari kita lihat cara melakukannya. Kamu akan memperkenalkan beberapa musuh untuk dilawan oleh pemain. Ini memberikan jalan menuju ketentuan menang - menyingkirkan semua musuh dari game!

Buat file enemy.dart di direktori lib/components, lalu tambahkan kode berikut:

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

Dari interaksi Anda sebelumnya dengan komponen Player dan Brick, sebagian besar file ini seharusnya sudah Anda pahami. Namun, akan ada beberapa garis bawah merah di editor Anda karena class dasar baru yang tidak diketahui. Tambahkan class ini sekarang dengan menambahkan file bernama body_component_with_user_data.dart ke lib/components yang berisi konten berikut:

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

Class dasar ini, yang dikombinasikan dengan callback beginContact baru di komponen Enemy, menjadi dasar untuk mendapatkan notifikasi secara terprogram tentang dampak antar-isi. Sebenarnya, Anda perlu mengedit setiap komponen yang ingin Anda gunakan untuk menerima notifikasi dampak. Jadi, lanjutkan dan edit komponen Brick, Ground, dan Player untuk menggunakan BodyComponentWithUserData ini sebagai pengganti class dasar BodyComponent yang saat ini digunakan komponen tersebut. Misalnya, berikut cara mengedit komponen 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),
            ),
          ],
        );
}

Untuk informasi selengkapnya tentang cara Forge2d menangani kontak, lihat dokumentasi Forge2D tentang callback kontak.

Memenangkan game

Setelah kamu memiliki musuh dan cara untuk mengusir musuh dari dunia, ada cara sederhana untuk mengubah simulasi ini menjadi game. Buat sasaran untuk menyingkirkan semua musuh! Waktunya mengedit file game.dart sebagai berikut:

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

Tantangan Anda, jika Anda memilih untuk menerimanya, adalah menjalankan game dan menampilkan diri Anda di layar ini.

Jendela aplikasi dengan bukit hijau di latar belakang, lapisan tanah, blok di atas tanah, dan overlay teks &#39;Anda menang!&#39;

8. Selamat

Selamat, Anda berhasil membangun game dengan Flutter dan Flame!

Anda telah membangun game menggunakan game engine Flame 2D dan menyematkannya di wrapper Flutter. Anda telah menggunakan Efek Flame untuk menganimasikan dan menghapus komponen. Anda telah menggunakan paket Google Fonts dan Flutter Animate untuk membuat seluruh game terlihat dengan baik.

Apa langkah selanjutnya?

Lihat beberapa codelab ini...

Bacaan lebih lanjut