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:
Wymagania wstępne
- Ukończenie ćwiczenia z programowania Wprowadzenie do Flame with Flutter
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:
- 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.
- 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:
- 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.
- 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.
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:
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.
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.
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.
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.
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ę.
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.
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.
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...
- Tworzenie interfejsów nowej generacji w Flutter
- Zmiana aplikacji Flutter z nudnej na piękną
- Dodawanie zakupów w aplikacji do aplikacji Flutter