1. Zanim zaczniesz
Flame to silnik gier 2D oparty na Flutterze. W tym ćwiczeniu w Codelab utworzysz grę Forge2D, która wykorzystuje 2D symulację fizyki na wzór Box2D. Komponenty Flame służą do odwzorowania na ekranie symulowanej rzeczywistości fizycznej, z której mogą korzystać użytkownicy. Po zakończeniu tworzenia gra powinna wyglądać tak jak na tym animowanym GIF-ie:
Wymagania wstępne
- Ukończenie ćwiczenia Wprowadzenie do Flame w Flutterze
Czego się nauczysz
- Podstawy działania Forge2D, począwszy od różnych typów obiektów fizycznych.
- Jak skonfigurować symulację fizyczną w 2D.
Wymagania
Kompilator dla wybranego celu programowania. Ten projekt kodu działa na wszystkich 6 platformach obsługiwanych przez Fluttera. Do tworzenia aplikacji na Windowsa potrzebujesz Visual Studio, do tworzenia aplikacji na macOS lub iOS – Xcode, a do tworzenia aplikacji na Androida – Android Studio.
2. Utwórz projekt
Tworzenie projektu Flutter
Projekt Fluttera można utworzyć na wiele sposobów. W tej sekcji, ze względu na zwiękłą ilość tekstu, użyjesz wiersza poleceń.
Aby rozpocząć, wykonaj następujące czynności:
- Utwórz projekt Flutter w wierszu poleceń:
$ 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.
- Zmień 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.4.0 (from transitive dependency to direct dependency) + flame 1.29.0 + flame_forge2d 0.19.0+2 + flame_kenney_xml 0.1.1+12 flutter_lints 5.0.0 (6.0.0 available) + forge2d 0.14.0 leak_tracker 10.0.9 (11.0.1 available) leak_tracker_flutter_testing 3.0.9 (3.0.10 available) leak_tracker_testing 3.0.1 (3.0.2 available) lints 5.1.1 (6.0.0 available) material_color_utilities 0.11.1 (0.13.0 available) meta 1.16.0 (1.17.0 available) + ordered_set 8.0.0 + petitparser 6.1.0 (7.0.0 available) test_api 0.7.4 (0.7.6 available) vector_math 2.1.4 (2.2.0 available) vm_service 15.0.0 (15.0.2 available) + xml 6.5.0 (6.6.0 available) Changed 8 dependencies! 12 packages have newer versions incompatible with dependency constraints. Try `flutter pub outdated` for more information.
Pakiet flame
jest Ci znany, ale pozostałe 3 pakiety mogą wymagać wyjaśnienia. Pakiet characters
służy do manipulowania ścieżkami w sposób zgodny z UTF-8. Pakiet flame_forge2d
udostępnia funkcje Forge2D w sposób, który dobrze współpracuje z Flame. Na koniec pakiet xml
jest używany w różnych miejscach do odczytu i modyfikowania treści XML.
Otwórz projekt, a potem zastąp zawartość pliku lib/main.dart
tym kodem:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}
Uruchamia aplikację z użyciem GameWidget
, który tworzy instancję FlameGame
. W tym CodeLab nie ma kodu Fluttera, który używa stanu instancji gry do wyświetlania informacji o uruchomionej grze, więc uproszczone bootstrap działa dobrze.
Opcjonalnie: wykonaj zadanie poboczne dostępne tylko w macOS
Zrzuty ekranu w tym projekcie pochodzą z gry jako aplikacji na komputer Mac. Aby pasek tytułu aplikacji nie odwracał uwagi od gry, możesz zmodyfikować konfigurację projektu w programie do uruchamiania na komputerze Mac, aby usunąć pasek tytułu.
W tym celu wykonaj następujące czynności:
- Utwórz plik
bin/modify_macos_config.dart
i dodaj do niego ten kod:
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());
}
Plik ten nie znajduje się w katalogu lib
, ponieważ nie jest częścią kodu źródłowego środowiska uruchomieniowego gry. To narzędzie wiersza poleceń służące do modyfikowania projektu.
- W katalogu bazowym 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 na linii poleceń. Zmodyfikuje on jednak plik konfiguracji macos/Runner/Base.lproj/MainMenu.xib
, aby uruchomić grę bez widocznej paska tytułu, a gra Flame zajmowała cały ekran.
Uruchom grę, aby sprawdzić, czy wszystko działa. Powinien wyświetlić się nowe okno z czarnym tłem.
3. Dodawanie komponentów z obrazem
Dodaj obrazy
Każda gra potrzebuje zasobów graficznych, aby móc wyświetlać ekran w sposób, który sprawia przyjemność. W tym laboratorium kodu użyjesz pakietu Physics Assets z witryny Kenney.nl. Te zasoby są objęte licencją Creative Commons CC0, ale nadal zdecydowanie zalecam przekazanie darowizny zespołowi Kenney, aby mógł kontynuować swoją świetną pracę. Tak.
Aby umożliwić korzystanie z komponentów Kenney, musisz zmodyfikować plik konfiguracji pubspec.yaml
. 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.8.1
dependencies:
flutter:
sdk: flutter
characters: ^1.4.0
flame: ^1.29.0
flame_forge2d: ^0.19.0+2
flame_kenney_xml: ^0.1.1+12
xml: ^6.5.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
assets: # Add from here
- assets/
- assets/images/ # To here.
Flame wymaga, aby zasoby obrazów znajdowały się w folderze assets/images
, ale można to skonfigurować inaczej. Więcej informacji znajdziesz w dokumentacji Flame dotyczącej zdjęć. Po skonfigurowaniu ścieżek musisz je dodać do projektu. Jednym ze sposobów jest użycie wiersza poleceń w ten sposób:
mkdir -p assets/images
Polecenie mkdir
nie powinno zwrócić żadnego wyniku, ale nowy katalog powinien być widoczny w edytorze lub eksploratorze plików.
Rozwiń pobrany plik kenney_physics-assets.zip
. Powinieneś zobaczyć 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
projektu.
Dostępne są też arkusze sprite’ów. Są to kombinacje obrazu PNG i pliku XML, który opisuje, gdzie na obrazie spritesheet znajdują się mniejsze obrazy. Arkusze sprite to technika, która pozwala skrócić czas wczytywania, ponieważ zamiast dziesiątek lub setek pojedynczych plików obrazów wczytuje się tylko jeden plik.
Skopiuj pliki spritesheet_aliens.png
, spritesheet_elements.png
i spritesheet_tiles.png
do katalogu assets/images
projektu. Podczas tej operacji skopiuj też pliki spritesheet_aliens.xml
, spritesheet_elements.xml
i spritesheet_tiles.xml
do katalogu assets
projektu. Projekt powinien wyglądać tak.
Malowanie tła
Teraz, gdy do projektu zostały dodane komponenty z obrazem, możesz umieścić je na ekranie. Jeden obraz na ekranie. Więcej informacji znajdziesz w kolejnych krokach.
Utwórz plik o nazwie background.dart
w nowym katalogu o nazwie lib/components
i dodaj do niego ten kod.
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
. Jest odpowiedzialny za wyświetlanie jednego z 4 obrazów tła Kenney.nl. W tym kodzie występują pewne uproszczenia. Po pierwsze, obrazy są kwadratowe, a wszystkie 4 obrazy tła z Kenny są kwadratowe. Po drugie, rozmiar widocznego świata nigdy się nie zmieni, ponieważ w przeciwnym razie ten komponent musiałby obsługiwać zdarzenia zmiany rozmiaru gry. Trzecie założenie polega na tym, że pozycja (0,0)
będzie znajdować się w środku ekranu. Te założenia wymagają specjalnej konfiguracji CameraComponent
gry.
Utwórz kolejny nowy 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();
}
}
Tutaj dużo się dzieje. Zacznij od zajęć MyPhysicsGame
. W przeciwieństwie do poprzedniego Codelab rozszerza ona Forge2DGame
, a nie FlameGame
. Forge2DGame
rozszerza FlameGame
o kilka ciekawych funkcji. Po pierwsze, domyślnie parametr zoom
ma wartość 10. To ustawienie zoom
dotyczy zakresu przydatnych wartości, z którymi dobrze współpracują silniki symulacji fizyki w stylu Box2D
. Silnik jest napisany w systemie MKS, w którym przyjmuje się, że jednostki to metry, kilogramy i sekundy. Zakres, w którym nie występują zauważalne błędy matematyczne obiektów, wynosi od 0,1 metra do 10 metrów. Podanie wymiarów w pikselach bez zastosowania pewnego poziomu skalowania w dół spowodowałoby, że Forge2D nie byłoby już przydatne. Przydatne podsumowanie to symulowanie obiektów o rozmiary od puszki po napoje po autobus.
Założenia w komponencie tła są spełnione przez ustawienie rozdzielczości CameraComponent
na 800 x 600 pikseli. Oznacza to, że obszar gry będzie miał 80 jednostek szerokości i 60 jednostek wysokości, a jego środek będzie znajdować się w miejscu (0,0)
. Nie ma to wpływu na wyświetlaną rozdzielczość, ale wpłynie na to, gdzie umieszczamy obiekty w scenie gry.
Obok argumentu konstruktora camera
znajduje się inny argument oparty na fizyce o nazwie gravity
. Grawitacja jest ustawiona na Vector2
, a x
to 0
, a y
to 10
. Wartość 10
jest zbliżeniem powszechnie akceptowanej wartości 9,81 m/s² dla przyspieszenia ziemskiego. Fakt, że grawitacja jest ustawiona na dodatnią 10, pokazuje, że w tym systemie kierunek osi Y jest skierowany w dół. Jest to inne podejście niż w Box2D, ale zgodne ze zwykłą konfiguracją Flame.
Następnie użyjemy metody onLoad
. Ta metoda jest asynchroniczna, co jest odpowiednie, ponieważ odpowiada za wczytywanie komponentów z obrazu z dysku. Wywołania funkcji images.load
zwracają Future<Image>
, a efekt uboczny to zapisanie załadowanego obrazu w obiekcie Game. Te przyszłe wartości są zbierane i oczekiwane jako pojedyncza jednostka za pomocą statycznej metody Futures.wait
. Lista zwróconych obrazów jest następnie dopasowywana do wzorca w poszczególnych nazwach.
Obrazy spritesheet są następnie przekazywane do serii obiektów XmlSpriteSheet
, które odpowiadają za pobieranie sprite’ów o indywidualnych nazwach zawartych w arkuszu sprite’ów. Klasa XmlSpriteSheet
jest zdefiniowana w pakiecie flame_kenney_xml
.
Po wykonaniu tych czynności wystarczy wprowadzić kilka drobnych zmian w lib/main.dart
, aby obraz pojawił się na ekranie.
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
}
Po wprowadzeniu tej zmiany możesz ponownie uruchomić grę, aby zobaczyć tło na ekranie. Pamiętaj, że instancja kamery CameraComponent.withFixedResolution()
doda letterbox, aby uzyskać proporcje 800 x 600 w grze.
4. Dodawanie podłoża
coś, na czym można budować.
Jeśli gra ma grawitację, potrzebujemy czegoś, co będzie wyłapywać obiekty, zanim spadną na dół ekranu. Chyba że wypadanie z ekranu jest częścią projektu gry. Utwórz w katalogu lib/components
nowy plik ground.dart
i dodaj do niego te informacje:
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 poziomu BodyComponent
. W Forge2D ważne są ciała, czyli obiekty będące częścią dwuwymiarowej symulacji fizycznej. BodyDef
tego komponentu ma mieć BodyType.static
.
W Forge2D obiekty mają 3 rodzaje. Statyczne obiekty się nie poruszają. Mają one zerową masę – nie reagują na grawitację – i nieskończoną masę – nie poruszają się, gdy uderzą w nie inne obiekty, bez względu na ich ciężar. Dzięki temu statyczne obiekty są idealne do tworzenia powierzchni naziemnej, ponieważ się nie poruszają.
Pozostałe 2 typy ciał to kinematyczne i dynamiczne. Ciało dynamiczne to ciało, które jest całkowicie symulowane, reaguje na grawitację i obiekty, w które uderza. W dalszej części tego Codelab zobaczysz wiele dynamicznych ciał. Ciało kinematyczne to coś pośredniego między ciałem dynamicznym a statycznym. poruszają się, ale nie reagują na grawitację ani inne obiekty. przydatne, ale wykraczające poza zakres tego ćwiczenia z programowania;
Sam tekst nie robi zbyt wiele. Ciało musi mieć powiązane kształty, aby miało znaczenie. W tym przypadku ten element ma jeden powiązany kształt, czyli PolygonShape
ustawiony jako BoxXY
. Ten typ pola ma oś wyrównaną z orientacją świata, w przeciwieństwie do PolygonShape
ustawionego jako BoxXY
, który można obracać wokół punktu obrotu. To też przydatne, ale wykracza poza zakres tego Codelab. Kształt i korpus są połączone za pomocą uchwytu, co jest przydatne do dodawania do systemu takich elementów jak friction
.
Domyślnie ciało renderuje przyłączone kształty w sposób przydatny do debugowania, ale nie zapewniający dobrej rozgrywki. Ustawienie argumentu super
renderBody
na false
powoduje wyłączenie tego renderowania debugowania. Za udostępnienie tego ciała do renderowania w grze odpowiada dziecko SpriteComponent
.
Aby dodać do gry komponent Ground
, zmodyfikuj 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
, używając pętli for
w kontekście List
i przekazując powstałą listę komponentów Ground
metodzie addAll
klasy world
.
Uruchomienie gry powoduje wyświetlenie tła i podłoża.
5. Dodawanie cegieł
Budowanie ściany
Ziemia jest przykładem ciała stałego. Teraz nadszedł czas na pierwszy komponent dynamiczny. Dynamiczne komponenty w Forge2D są podstawą rozgrywki. To one się poruszają i wchodzą w interakcje ze światem. Na tym etapie wprowadzisz klocki, które będą losowo wybierane i wyświetlane na ekranie w grupie. Zobaczysz, jak spadają i uderzały o siebie nawzajem.
Elementy zostaną utworzone na podstawie arkusza sprite. Jeśli spojrzysz na opis arkusza sprite’ów w assets/spritesheet_elements.xml
, zauważysz, że mamy ciekawy problem. Nazwy nie wydają się zbyt pomocne. Przydatne byłoby wybranie cegły według typu materiału, rozmiaru i stopnia uszkodzenia. Na szczęście pomocny elf poświęcił trochę czasu na odkrycie wzoru w nazwach plików i utworzył narzędzie, które ułatwia wszystkim pracę. Utwórz w katalogu bin
nowy plik generate_brick_file_names.dart
i dodaj do niego ten kod:
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 komunikat o braku zależności. Dodaj je za pomocą tego polecenia:
flutter pub add equatable
Teraz możesz uruchomić ten program w ten 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', }, }; }
To narzędzie przeanalizowało plik opisu sprite’a i przekształciło go w kod Darta, którego możemy użyć do wybrania odpowiedniego pliku obrazu dla każdej kostki, którą 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();
}
}
Teraz możesz zobaczyć, jak wygenerowany wcześniej kod Dart jest zintegrowany z tym kodem źródłowym, aby umożliwić szybkie wybieranie obrazów cegieł na podstawie materiału, rozmiaru i stanu. Jeśli spojrzysz na sam komponent Brick
, zauważysz, że większość tego kodu jest podobna do kodu komponentu Ground
z poprzedniego kroku.enum
Tutaj jest stan zmienny, który pozwala na uszkodzenie cegły, ale wykorzystanie tego pozostawiam jako ćwiczenie dla czytelnika.
Czas na pokazanie klocków na ekranie. Zmień 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 kod jest nieco inny niż kod, którego użyjesz do dodania komponentów Ground
. Tym razem Brick
są dodawane w losowym klastrze z upływem czasu. Jest to 2-częściowy proces. Po pierwsze, metoda, która dodaje Brick
s await
s Future.delayed
, jest asynchronicznym odpowiednikiem wywołania sleep()
. Jest jednak jeszcze jeden warunek, który musi zostać spełniony: wywołanie funkcji addBricks
w metodzie onLoad
nie może być await
owane. W przeciwnym razie metoda onLoad
nie zostanie ukończona, dopóki wszystkie cegiełki nie znajdą się na ekranie. Zawijanie wywołania addBricks
w wywołaniu unawaited
sprawia, że narzędzia do sprawdzania kodu są zadowolone, a nasz zamiar jest oczywisty dla przyszłych programistów. Nie czekanie na zwrócenie wartości przez tę metodę jest celowe.
Uruchom grę, a zobaczysz, jak pojawiają się cegły, uderzają w siebie i rozsypują się na ziemi.
6. Dodawanie odtwarzacza
Rzuć kosmitami w cegły
Obserwowanie, jak klocki się przewracają, jest fajne przez pierwsze kilka razy, ale przypuszczam, że gra będzie jeszcze ciekawsza, jeśli damy graczowi awatara, którego będzie można wykorzystać do interakcji ze światem. Może jakiś kosmita, którego można rzucać w cegły?
Utwórz w katalogu lib/components
nowy plik player.dart
i dodaj do niego te informacje:
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.withAlpha(180)
..strokeWidth = 0.4
..strokeCap = StrokeCap.round,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
Jest to wyższa wersja komponentów Brick
z poprzedniego kroku. Ten komponent Player
ma 2 komponenty podrzędne: SpriteComponent
, który powinieneś już znać, oraz CustomPainterComponent
, który jest nowy. Koncepcja CustomPainter
pochodzi z Fluttera i pozwala na tworzenie obrazu na płótnie. Jest on używany, aby poinformować gracza, gdzie pofrunie obcy, gdy zostanie rzucony.
Jak gracz inicjuje wyrzucenie kosmity? za pomocą gestu przeciągania, który komponent Player wykrywa za pomocą wywołań zwrotnych DragCallbacks
; Osoby o bystrym wzroku mogły zauważyć coś jeszcze.
Elementy Ground
były ciałami statycznymi, a elementy Brick – ciałami dynamicznymi. Gracz jest tu kombinacją obu. Na początku odtwarzacz jest nieruchomy i czeka na przeciągnięcie przez gracza. Po zwolnieniu przeciągnięcia staje się dynamiczny, dodaje impuls liniowy proporcjonalny do przeciągnięcia i pozwala na lot awatara obcego.
W komponencie Player
jest też kod, który usuwa go z ekranu, jeśli wykracza poza wyznaczone granice, przechodzi w stan uśpienia lub się wygasza. Celem jest umożliwienie graczowi wyrzucenia kosmity, sprawdzenia, co się stanie, i powtórzenia tej czynności.
Zintegruj komponent Player
z grą, edytując 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.
}
Dodanie gracza do gry jest podobne do dodawania poprzednich komponentów, ale z jedną dodatkową funkcją. Obcy gracza jest tak skonstruowany, że w określonych warunkach znika z gry. Dlatego jest tu obsługiwany mechanizm aktualizacji, który sprawdza, czy w grze nie ma komponentu Player
. Jeśli go nie ma, dodaje go z powrotem. Uruchamianie gry wygląda tak.
7. Reakcja na wpływ
Dodaj wrogów
Obiekty statyczne i dynamiczne wchodzą ze sobą w interakcje. Aby jednak coś osiągnąć, musisz w kodzie umieścić wywołania zwrotne, które będą wykonywane, gdy coś się zderzy. W tym kroku przedstawisz graczowi wrogów, z którymi będzie walczyć. To daje drogę do zwycięstwa – usunięcie wszystkich wrogów z gry.
Utwórz w katalogu lib/components
plik enemy.dart
i dodaj te informacje:
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();
}
Jeśli wcześniej korzystałeś(-aś) z elementów Player i Brick, większość tego pliku powinna być Ci znajoma. W edytorze pojawi się jednak kilka czerwonych podkreśleń z powodu nowej nieznanej klasy bazowej. Dodaj tę klasę, dodając do folderu lib/components
plik o nazwie body_component_with_user_data.dart
z następującą zawartoś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ę automatycznego otrzymywania powiadomień o kolizjach między obiektami. Musisz edytować wszystkie komponenty, które mają wysyłać powiadomienia o wpływie. Zmień więc komponenty Brick
, Ground
i Player
, aby używały klasy bazowej BodyComponentWithUserData
zamiast klasy bazowej BodyComponent
, której używają te komponenty. Oto przykład edytowania komponentu 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 na temat wywołań zwrotnych kontaktów.
Wygraj grę
Teraz, gdy masz już wrogów i sposób na ich usunięcie ze świata, możesz w prosty sposób przekształcić tę symulację w grę. Celem jest usunięcie wszystkich wrogów. Zmień plik 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.
}
}
Twoje zadanie, jeśli je przyjmiesz, polega na uruchomieniu gry i wejściu na ten ekran.
8. Gratulacje
Gratulacje, udało Ci się stworzyć grę za pomocą Flutter i Flame.
Użyto silnika 2D Flame do utworzenia gry i osadzono ją w opakowaniu Flutter. Użyłeś(-aś) efektów Flame do animowania i usuwania komponentów. Użyłeś pakietów Google Fonts i Flutter Animate, aby cała gra wyglądała estetycznie.
Co dalej?
Zapoznaj się z tymi ćwiczeniami z programowania
- Tworzenie interfejsów nowej generacji w Flutterze
- Jak sprawić, aby aplikacja Flutter była ładna, a nie nudna
- Dodawanie zakupów w aplikacji do aplikacji Flutter