Zbuduj grę 2D z fizyką w grze Flutter and Flame

1. Zanim zaczniesz

Flame to oparty na technologii Flutter silnik gry 2D. W ramach tego ćwiczenia w programie tworzysz grę wykorzystującą symulację fizyki 2D na podstawie wiersza Box2D o nazwie Forge2D. Za pomocą komponentów Flame malujesz na ekranie symulowaną rzeczywistość fizyczną, którą użytkownicy mogą bawić. Po zakończeniu gra powinna wyglądać podobnie do tego animowanego GIF-a:

Animacja przedstawiająca rozgrywkę z fizyką 2D

Wymagania wstępne

Czego się nauczysz

  • Podstawy działania gry Forge2D, zaczynając od różnych typów ciał fizycznych.
  • Jak skonfigurować symulację fizyczną w 2D.

Wymagania

Skompiluj oprogramowanie na potrzeby wybranego środowiska programistycznego. To ćwiczenie w Codelabs działa na wszystkich 6 platformach obsługiwanych przez Flutter. Musisz mieć Visual Studio, jeśli chcesz kierować reklamy na system Windows, Xcode, aby kierować reklamy na macOS lub iOS, oraz Android Studio, jeśli chcesz kierować treści na Androida.

2. Utwórz projekt

Tworzenie projektu Flutter

Projekt Flutter możesz utworzyć na wiele sposobów. W tej sekcji użyjesz wiersza poleceń, aby zwiększyć zwięzłość tekstu.

Aby rozpocząć, wykonaj następujące czynności:

  1. W wierszu poleceń utwórz projekt 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. Zmodyfikuj zależności projektu, aby dodać Flame i 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.

Pakiet flame jest już Ci znany, ale pozostałe 3 z nich mogą wymagać wyjaśnienia. Pakiet characters jest używany do manipulowania ścieżkami plików w sposób zgodny z UTF8. Pakiet flame_forge2d ujawnia funkcje Forge2D w sposób, który dobrze współpracuje z Fampem. Pakiet xml jest używany w różnych miejscach do przetwarzania i modyfikowania treści XML.

Otwórz projekt, a następnie zastąp zawartość pliku lib/main.dart tym:

lib/main.dart

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

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

Spowoduje to uruchomienie aplikacji przy użyciu interfejsu GameWidget, który tworzy instancję FlameGame. W tym ćwiczeniu w programowaniu nie ma kodu Flutter, który wykorzystuje stan instancji gry do wyświetlania informacji o uruchomionej grze, więc to uproszczone wczytywanie dobrze się sprawdza.

Opcjonalnie: wykonaj kurs poboczny tylko dla systemu macOS

Zrzuty ekranu w tym projekcie pochodzą z gry jako aplikacji komputerowej na macOS. Aby pasek tytułu aplikacji nie przeszkadzał w korzystaniu z aplikacji, możesz zmodyfikować konfigurację projektu w uruchomieniu systemu macOS tak, aby pasek tytułu nie był widoczny.

W tym celu wykonaj następujące czynności:

  1. Utwórz plik bin/modify_macos_config.dart i dodaj do niego tę treść:

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

Ten plik nie znajduje się w katalogu lib, ponieważ nie należy do bazy kodu środowiska wykonawczego gry. To narzędzie wiersza poleceń używane do modyfikowania projektu.

  1. Z katalogu podstawowego projektu uruchom narzędzie w ten sposób:
$ dart bin/modify_macos_config.dart

Jeśli wszystko pójdzie zgodnie z planem, program nie wygeneruje żadnych danych wyjściowych w wierszu poleceń. Plik konfiguracyjny macos/Runner/Base.lproj/MainMenu.xib zmodyfikuje jednak wtedy, gdy gra zostanie uruchomiona bez widocznego paska tytułu, a grę Flame zajmie całe okno.

Uruchom grę, aby sprawdzić, czy wszystko działa. Powinno wyświetlić się nowe okno z tylko pustym czarnym tłem.

Okno aplikacji z czarnym tłem, na którym nic na pierwszym planie

3. Dodaj komponenty z obrazem

Dodaj obrazy

Aby malować ekran w sposób zapewniający rozrywkę, każda gra wymaga zasobów graficznych. W tym ćwiczeniu w Codelabs będzie używany pakiet Fisics Assets (Zasoby fizyki) z Kenney.nl. Zasoby te są objęte licencją Creative Commons CC0, ale zdecydowanie zalecamy przekazanie darowizny zespołowi Kenney, który może kontynuować swoją pracę. Tak.

Musisz zmodyfikować plik konfiguracji pubspec.yaml, aby umożliwić korzystanie z zasobów Kenneya. Zmień go w ten sposób:

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 oczekuje, że komponenty z obrazem będą znajdować się w regionie assets/images, ale można to skonfigurować inaczej. Więcej informacji znajdziesz w dokumentacji dotyczącej obrazów Flame. Po skonfigurowaniu ścieżek musisz dodać je do samego projektu. Aby to zrobić, użyj wiersza poleceń w ten sposób:

$ mkdir -p assets/images

Polecenie mkdir nie powinno generować danych wyjściowych, ale nowy katalog powinien być widoczny w edytorze lub eksploratorze plików.

Rozwiń pobrany plik kenney_physics-assets.zip. Powinno pojawić się coś takiego:

Plik z listą plików pakietu kenney_physics-assets z zaznaczonym katalogiem PNG/Backgrounds

Z katalogu PNG/Backgrounds skopiuj pliki colored_desert.png, colored_grass.png, colored_land.png i colored_shroom.png do katalogu assets/images swojego projektu.

Dostępne są też arkusze sprite. Jest to połączenie obrazu PNG i pliku XML opisującego, gdzie w obrazie arkusza sprite można znaleźć mniejsze obrazy. Arkusze sprite to technika skracania czasu wczytywania dzięki ładowaniu pojedynczego pliku zamiast dziesiątek, a nawet setek pojedynczych plików graficznych.

Rozwinięta lista plików pakietu kenney_physics-assets z zaznaczonym katalogiem Spritesheet

Skopiuj pliki spritesheet_aliens.png, spritesheet_elements.png i spritesheet_tiles.png do katalogu assets/images projektu. Będąc tutaj, skopiuj też pliki spritesheet_aliens.xml, spritesheet_elements.xml i spritesheet_tiles.xml do katalogu assets swojego projektu. Twój projekt powinien wyglądać mniej więcej tak.

Lista plików katalogu projektów forge2d_game z zaznaczonym katalogiem zasobów

Pomaluj tło

Po dodaniu do projektu komponentów z obrazem czas umieścić je na ekranie. Jeden obraz na ekranie. W kolejnych krokach pojawią się kolejne.

W nowym katalogu o nazwie lib/components utwórz plik o nazwie background.dart i dodaj tę treść.

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

Ten komponent to wyspecjalizowany SpriteComponent. Odpowiada za wyświetlanie jednego z 4 obrazów tła na stronie Kenney.nl. W tym kodzie przyjęto kilka upraszczających założenia. Po pierwsze, obrazy są kwadratowe, czyli wszystkie cztery obrazy tła od Kenneya. Po drugie rozmiar widocznego świata nigdy się nie zmieni, bo w przeciwnym razie ten komponent będzie musiał obsługiwać zdarzenia zmiany rozmiaru gry. Trzecie założenie zakłada, że pozycja (0,0) będzie na środku ekranu. Te założenia wymagają określonej konfiguracji CameraComponent gry.

Utwórz kolejny plik o nazwie game.dart w katalogu 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();
  }
}

Wiele się tutaj dzieje. Zacznijmy od zajęć MyPhysicsGame. W przeciwieństwie do poprzedniego ćwiczenia w Codelabs ten zakres obejmuje Forge2DGame, a nie FlameGame. Forge2DGame zawiera rozszerzenie FlameGame, wprowadzając kilka ciekawych ulepszeń. Po pierwsze, pole zoom ma domyślnie wartość 10. To ustawienie zoom ma związek z zakresem przydatnych wartości, z którymi dobrze współpracują silniki symulacyjne stylu fizyki stylu Box2D. W mechanizmie jest używany system MKS, w którym przyjmuje się jednostki wyrażone w metrach, kilogramach i sekundach. Zakres, w którym nie widzisz widocznych błędów matematycznych obiektów, wynosi od 0,1 do 10 s. Bezpośrednie wprowadzanie wymiarów w pikselach bez pewnego poziomu skalowania w dół cofałoby forge2D poza realną kopertę. Przydatne podsumowanie to na przykład symulowanie obiektów z zasięgu napoju gazowanego do autobusu.

Założenia dotyczące komponentu Tło są tu spełnione, poprawiając rozdzielczość elementu CameraComponent do 800 x 600 pikseli wirtualnych. Oznacza to, że obszar gry będzie miał 80 jednostek szerokości i 60 jednostek wysokości ze środkiem (0,0). Nie ma to wpływu na wyświetlaną rozdzielczość, ale wpłynie to na miejsce, w którym umieścisz obiekty w scenie w grze.

Oprócz argumentu konstruktora camera istnieje inny argument bardziej wyrównany z fizyką, o nazwie gravity. Grawitacja jest ustawiona na Vector2, gdzie x wynosi 0, a y wynosi 10. Liczba 10 to przybliżone przybliżone, ogólnie przyjęte wartości 9,81 metra na sekundę dla grawitacji. Jeśli grawitacja jest ustawiona na wartość dodatnią 10, oznacza to, że w tym systemie kierunek osi Y jest niższy. Różni się to ogólnie od Box2D, ale jest zgodne ze sposobem konfigurowania Flame.

Kolejna metoda to onLoad. Ta metoda jest asynchroniczna, co jest odpowiednie, ponieważ odpowiada za ładowanie zasobów graficznych z dysku. Wywołania images.load zwracają Future<Image>, a efekt uboczny powoduje zapisanie obrazu w obiekcie gry w pamięci podręcznej. Te transakcje przyszłości są zbierane i oczekiwane jako jedna jednostka przy użyciu metody statycznej Futures.wait. Lista zwróconych obrazów jest następnie dopasowywana do wzorca w poszczególnych nazwach.

Obrazy w arkuszu sprite są następnie wprowadzane do serii obiektów XmlSpriteSheet, które odpowiadają za pobranie elementów sprite o oddzielnych nazwach zawartych w arkuszu sprite. Klasa XmlSpriteSheet jest zdefiniowana w pakiecie flame_kenney_xml.

Aby pokazać się na ekranie, wystarczy kilka drobnych zmian w elemencie lib/main.dart.

lib/main.dart

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

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

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

Dzięki tej prostej zmianie możesz teraz uruchomić grę ponownie i zobaczyć tło na ekranie. Pamiętaj, że w przypadku instancji kamery w CameraComponent.withFixedResolution() wymagane jest dodanie w grze formatu letterbox, aby proporcje 800 x 600 działały.

Okno aplikacji z obrazem w tle falistych zielonych wzgórz i dziwnie abstrakcyjnych drzew.

4. Dodawanie terenu

Na czym można polegać

Jeśli działa grawitacja, potrzebujemy czegoś, co złapie obiekty w grze, zanim spadną z dołu ekranu. Oczywiście, chyba że upadek nie leży w Twoim guście. Utwórz nowy plik ground.dart w katalogu lib/components i dodaj do niego następujący plik:

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

Ten komponent Ground pochodzi z: BodyComponent. W Forge2D ciała są ważne – są częścią dwuwymiarowej symulacji fizycznej. Pole BodyDef tego komponentu musi zawierać właściwość BodyType.static.

W Forge2D mamy 3 typy ciała. Ciało statyczne nie porusza się. W praktyce mają one zarówno masę zerową, jak i nieskończoną – nie reagują na grawitację, ale nie poruszają się po uderzeniu przez inne obiekty, niezależnie od tego, jak ciężkie są. Dzięki temu ciała statyczne idealnie nadają się do powierzchni ziemi, ponieważ się nie porusza.

Pozostałe 2 rodzaje ciała to kinetyczna i dynamiczna. Ciała dynamiczne to w pełni symulowane ciała, które reagują na grawitację oraz na obiekty, na które wpadają. W pozostałej części tego ćwiczenia z programowania zobaczysz wiele elementów dynamicznych. Ciała Kinematyczne to połowa różnic między statycznym a dynamicznym. Poruszają się, ale nie reagują na grawitację ani inne uderzające w nie obiekty. Ta funkcja jest przydatna, ale wykracza poza zakres tego ćwiczenia z programowania.

Samo ciało nie robi zbyt wiele. Ciało musi mieć związane z nim kształty, aby mieć substancję. W tym przypadku z ciałem jest powiązany 1 kształt: PolygonShape ustawiony jako BoxXY. Ramka tego typu jest wyrównana do osi świata – w odróżnieniu od pola PolygonShape ustawionego jako BoxXY, które można obracać wokół punktu obrotu. Ta funkcja jest przydatna, ale wykracza poza zakres tego ćwiczenia. Kształt i ciało są połączone za pomocą osprzętu, który pomaga dodawać do systemu elementy takie jak friction.

Domyślnie treść renderuje dołączone kształty w sposób, który jest przydatny podczas debugowania, ale nie sprawdza się w rozgrywce. Ustawienie argumentu super renderBody na false wyłącza renderowanie debugowania. Za przekazanie tej treści do renderowania w grze odpowiada element podrzędny SpriteComponent.

Aby dodać do gry komponent Ground, edytuj plik game.dart w ten sposób.

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

Ta zmiana dodaje do świata serię komponentów Ground, wykorzystując pętlę for w kontekście List i przekazując wynikową listę komponentów Ground do metody addAll w world.

Uruchomienie gry pokazuje teraz tło i teren.

Okno aplikacji z tłem i warstwą terenu.

5. Dodaj klocki

Budowa ściany

Podstawa dała nam przykład ciała statycznego. Nadszedł czas na Twój pierwszy komponent dynamiczny. W grze Forge2D kluczowe komponenty dynamiczne to elementy, które poruszają się i wchodzą w interakcje z otaczającym ich światem. W tym kroku zaprezentujesz klocki, które zostaną losowo wybrane na ekran w grupie klocków. Zobaczymy, jak upadek i napuszczają się na siebie.

Klocki zostaną utworzone z arkusza sprite elementów. Jeśli spojrzysz na opis arkusza sprite w języku assets/spritesheet_elements.xml, zauważysz, że mamy interesujący problem. Nazwy nie są zbyt przydatne. Dobrze byłoby wybrać klocek na podstawie rodzaju materiału, jego rozmiaru i liczby uszkodzeń. Na szczęście pomocny elf trochę czasu poświęcił czas na odnajdywanie wzorców w nazewnictwie plików i stworzył narzędzie, które to ułatwi Wam wszystkim. Utwórz nowy plik generate_brick_file_names.dart w katalogu bin i dodaj tę treść:

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

Twój edytor powinien wyświetlić ostrzeżenie lub błąd dotyczący brakującej zależności. Dodaj go w ten sposób:

$ flutter pub add equatable

Teraz program powinien być uruchomiony w następujący sposób:

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

Dzięki temu narzędziu mogliśmy przeanalizować plik z opisem arkusza sprite i przekonwertować go na kod Dart, dzięki któremu będziemy mogli wybrać odpowiedni plik graficzny dla każdego klocka, który chcesz umieścić na ekranie. Przydatne!

Utwórz plik brick.dart z tą zawartością:

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

Możesz zobaczyć, jak wygenerowany powyżej kod DART jest zintegrowany z tą bazą kodu, dzięki czemu możesz szybko i łatwo wybierać obrazy z cegły na podstawie materiału, rozmiaru i stanu. Jeśli chodzi o komponenty enum i sam komponent Brick, większość tego kodu wydaje się dość znajoma z komponentu Ground z poprzedniego kroku. Możesz tu zmieniać stan klocka, by nie uległ uszkodzeniu, ale czytelnicy powinni oddać się jego używaniu.

Czas przenieść klocki na ekran. Zmodyfikuj plik game.dart w ten sposób:

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

Ten dodany kod różni się nieco od kodu użytego do dodania komponentów Ground. Tym razem elementy Brick są dodawane w losowym klastrze. Składa się on z 2 części. Pierwszą jest to, że metoda, która dodaje Brick await do elementu Future.delayed, jest asynchronicznym odpowiednikiem wywołania sleep(). Występuje jednak druga część tego procesu: wywołanie funkcji addBricks w metodzie onLoad nie jest await. Gdyby tak było, metoda onLoad nie zakończy się, dopóki wszystkie klocki nie pojawią się na ekranie. Uwzględnienie wywołania addBricks w wywołaniu unawaited sprawia, że linttery są przyjemne, a intencje są oczywiste dla przyszłych programistów. Nie oczekiwanie na zwrócenie metody jest celowe.

Gdy uruchomisz grę, pojawią się klocki, które zderzają się ze sobą i rozlewają na ziemię.

Okno aplikacji z zielonymi wzgórzami w tle, warstwą ziemi i blokami lądującymi na ziemi.

6. Dodaj odtwarzacz

rzucanie obcych w klocki.

Na początku obserwowanie upuszczenia klocków może być fajne, ale sądzę, że gra będzie jeszcze fajniejsza, jeśli damy graczowi awatar, przy użyciu którego będzie mógł wchodzić w interakcje ze światem. Co powiesz na kosmitę, który może rzucać klockami muru?

Utwórz nowy plik player.dart w katalogu lib/components i dodaj do niego następujący plik:

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

To krok w górę od komponentów Brick z poprzedniego kroku. Ten komponent Player ma 2 komponenty podrzędne: SpriteComponent, który musisz rozpoznać, i CustomPainterComponent, który jest nowy. Aplikacja CustomPainter pochodzi z platformy Flutter, dzięki której możesz malować na płótnie. Służy w tym miejscu do przekazania użytkownikom informacji o tym, dokąd poleci okrągły kosmita, gdy zostanie on odrzucony.

Jak gracz inicjuje rzucanie kosmitą? użycie gestu przeciągania wykrywanego przez komponent odtwarzacza za pomocą wywołań zwrotnych DragCallbacks. Gdy ukrył coś w pobliżu, orzeł zauważył tu coś innego.

Komponenty (Ground) były ciałami statycznymi, a z cegły – dynamicznymi. Odtwarzacz to połączenie obu tych elementów. Gracz rozpoczyna się jako element statyczny i czeka, aż gracz go przeciągnie. Po zwolnieniu podczas przeciągania zmienia się ze statycznego w dynamiczny, dodaje impuls liniowy proporcjonalnie do natężenia ruchu i pozwala kosmicznemu latać.

W komponencie Player jest też kod, który usuwa go z ekranu w przypadku przekroczenia limitu, zasypiania lub przekroczenia limitu czasu. Gracz ma za zadanie rzucić kosmitę, zobaczyć, co się stanie, a potem podjąć kolejną próbę.

Zintegruj komponent Player z grą, edytując element game.dart w ten sposób:

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

Dodawanie gracza do gry przebiega podobnie jak w przypadku poprzednich komponentów, ma tu tylko 1 dodatkowe zagniecenie. Kosmita jest zaprojektowany tak, aby w określonych warunkach usunąć się z gry, dlatego w tym miejscu znajduje się moduł obsługi aktualizacji, który sprawdza, czy w grze nie ma komponentu Player, a jeśli tak, dodaje go z powrotem. Sposób uruchamiania gry.

Okno aplikacji z zielonymi wzgórzami w tle, warstwą ziemi, klockami na ziemi i awatarem gracza w locie.

7. Reaguj na wpływ

Dodawanie wrogów

Zauważyliśmy, że obiekty statyczne i dynamiczne wchodzą ze sobą w interakcję. Aby jednak do tego celu rzeczywiście dotrzeć, trzeba w kodzie otrzymywać wywołania zwrotne, gdy coś się zderza. Jak to się robi. Wprowadzasz przeciwników, z którymi gracz będzie musiał się zmierzyć. To jest ścieżka do warunku zwycięstwa – usuń wszystkich wrogów z gry.

Utwórz plik enemy.dart w katalogu lib/components i dodaj:

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

Na podstawie Twoich wcześniejszych interakcji z komponentami odtwarzacza i klocków większość części tego pliku powinna być Ci znajoma. W edytorze pojawi się jednak kilka podkreśleń na czerwono z powodu nowej nieznanej klasy bazowej. Dodaj te zajęcia teraz, dodając do folderu lib/components plik o nazwie body_component_with_user_data.dart z następującą treścią:

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

Ta klasa podstawowa w połączeniu z nowym wywołaniem zwrotnym beginContact w komponencie Enemy stanowi podstawę programowego powiadamiania o wpływie między treściami. Musisz edytować komponenty, między którymi chcesz otrzymywać powiadomienia o wpływie. Edytuj komponenty Brick, Ground i Player, aby używać tej BodyComponentWithUserData zamiast klasy podstawowej BodyComponent, której te komponenty używają obecnie. Tak na przykład możesz edytować komponent 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),
            ),
          ],
        );
}

Więcej informacji o tym, jak Forge2d obsługuje kontakty, znajdziesz w dokumentacji Forge2D dotyczącej wywołań zwrotnych kontaktów.

Wygrana

Masz już wrogów i masz sposób na pozbycie się ich ze świata, dlatego możesz łatwo zmienić tę symulację w grę. Postaraj się usunąć wszystkich wrogów. Wprowadź zmiany w pliku game.dart w ten sposób:

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

Twoim wyzwaniem jest uruchomienie gry i dotarcie do tego ekranu.

Okno aplikacji z zielonymi wzgórzami w tle, warstwą ziemi, blokami na ziemi i nakładką tekstową „Wygrywasz!”.

8. Gratulacje

Gratulacje, udało Ci się utworzyć grę w grze Flutter i Płomień.

Udało Ci się stworzyć grę za pomocą silnika gry Flame 2D i umieścić ją w opakowaniu Flutter. Udało Ci się wykorzystać efekt Flame's Effects do animowania i usuwania komponentów. Udało Ci się wykorzystać pakiety Google Fonts i Flutter Animate, dzięki czemu gra wygląda dobrze.

Co dalej?

Zapoznaj się z tymi ćwiczeniami z programowania...

Więcej informacji