Informazioni su questo codelab
1. Prima di iniziare
Flame è un motore di gioco 2D basato su Flutter. In questo codelab, crei un gioco che utilizza una simulazione fisica 2D simile a Box2D chiamata Forge2D. Utilizzi i componenti di Flame per dipingere la realtà fisica simulata sullo schermo in modo che i tuoi utenti possano interagire con essa. Al termine, il gioco dovrebbe avere il seguente aspetto in formato GIF animata:
Prerequisiti
- Completare il codelab Introduzione a Flame con Flutter
Cosa imparerai
- Come funzionano le nozioni di base di Forge2D, a partire dai diversi tipi di corpi fisici.
- Come configurare una simulazione fisica in 2D.
Cosa serve
- L'SDK Flutter
- Visual Studio Code (VS Code) con i plug-in Flutter e Dart
Software del compilatore per la destinazione di sviluppo scelta. Questo codelab funziona su tutte e sei le piattaforme supportate da Flutter. Per scegliere come target Windows, devi utilizzare Visual Studio, per macOS o iOS Xcode e per Android Android Studio.
2. Crea un progetto
Crea il progetto Flutter
Esistono molti modi per creare un progetto Flutter. In questa sezione utilizzerai la riga di comando per brevità.
Per iniziare, procedi nel seguente modo:
- In una riga di comando, crea un progetto 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.
- Modifica le dipendenze del progetto per aggiungere Flame e 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.
Il pacchetto flame
ti è familiare, ma gli altri tre potrebbero richiedere qualche spiegazione. Il pacchetto characters
viene utilizzato per la manipolazione dei percorsi in conformità con UTF8. Il pacchetto flame_forge2d
espone la funzionalità Forge2D in modo che funzioni bene con Flame. Infine, il pacchetto xml
viene utilizzato in vari punti per consumare e modificare i contenuti XML.
Apri il progetto e sostituisci i contenuti del file lib/main.dart
con quanto segue:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
runApp(GameWidget.controlled(gameFactory: FlameGame.new));
}
L'app viene avviata con un GameWidget
che esegue l'inizializzazione dell'istanza FlameGame
. In questo codelab non è presente codice Flutter che utilizzi lo stato dell'istanza del gioco per visualizzare informazioni sul gioco in esecuzione, pertanto questo bootstrap semplificato funziona bene.
(Facoltativo) Completa una missione secondaria solo per macOS
Gli screenshot di questo progetto provengono dal gioco come app desktop per macOS. Per evitare che la barra delle app dell'app interrompa l'esperienza complessiva, puoi modificare la configurazione del progetto del runner per macOS in modo da eliminare la barra delle app.
Per farlo, segui questi passaggi:
- Crea un file
bin/modify_macos_config.dart
e aggiungi i seguenti contenuti:
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());
}
Questo file non si trova nella directory lib
perché non fa parte del codice di base di runtime del gioco. Si tratta di uno strumento a riga di comando utilizzato per modificare il progetto.
- Dalla directory di base del progetto, esegui lo strumento come segue:
dart bin/modify_macos_config.dart
Se tutto procede secondo i piani, il programma non genererà alcun output sulla riga di comando. Tuttavia, modificherà il file di configurazione macos/Runner/Base.lproj/MainMenu.xib
per eseguire il gioco senza una barra delle app visibile e con il gioco Flame che occupa l'intera finestra.
Esegui il gioco per verificare che tutto funzioni. Dovrebbe essere visualizzata una nuova finestra con solo uno sfondo nero vuoto.
3. Aggiungi asset immagine
Aggiungi le immagini
Qualsiasi gioco ha bisogno di risorse artistiche per poter dipingere una schermata in modo divertente. Questo codelab utilizzerà il pacchetto Physics Assets di Kenney.nl. Questi asset sono concessi in licenza con Creative Commons CC0, ma ti consiglio vivamente di fare una donazione al team di Kenney per consentirgli di continuare il grande lavoro che sta svolgendo. L'ho fatto.
Dovrai modificare il file di configurazione pubspec.yaml
per abilitare l'utilizzo degli asset di Kenney. Modificala come segue:
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 si aspetta che gli asset immagine si trovino in assets/images
, anche se questa impostazione può essere configurata in modo diverso. Per ulteriori dettagli, consulta la documentazione relativa alle immagini di Flame. Ora che hai configurato i percorsi, devi aggiungerli al progetto stesso. Un modo per farlo è utilizzare la riga di comando come segue:
mkdir -p assets/images
Non dovrebbe essere presente alcun output del comando mkdir
, ma la nuova directory dovrebbe essere visibile nell'editor o in un esploratore di file.
Espandi il file kenney_physics-assets.zip
che hai scaricato e dovresti visualizzare qualcosa di simile al seguente:
Dalla directory PNG/Backgrounds
, copia i file colored_desert.png
, colored_grass.png
, colored_land.png
e colored_shroom.png
nella directory assets/images
del progetto.
Esistono anche sprite sheet. Si tratta di una combinazione di un'immagine PNG e di un file XML che descrive dove è possibile trovare immagini più piccole nell'immagine spritesheet. Gli spritesheet sono una tecnica per ridurre i tempi di caricamento caricando un solo file anziché decine, se non centinaia, di singoli file immagine.
Copia spritesheet_aliens.png
, spritesheet_elements.png
e spritesheet_tiles.png
nella directory assets/images
del progetto. A questo punto, copia anche i file spritesheet_aliens.xml
, spritesheet_elements.xml
e spritesheet_tiles.xml
nella directory assets
del progetto. Il progetto dovrebbe avere il seguente aspetto.
Dipingi lo sfondo
Ora che hai aggiunto gli asset immagine al progetto, è il momento di visualizzarli sullo schermo. Beh, un'immagine sullo schermo. Nei passaggi successivi verranno fornite ulteriori informazioni.
Crea un file denominato background.dart
in una nuova directory denominata lib/components
e aggiungi i seguenti contenuti.
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,
),
);
}
}
Questo componente è un SpriteComponent
specializzato. È responsabile della visualizzazione di una delle quattro immagini di sfondo di Kenney.nl. Questo codice contiene alcuni presupposti semplificati. La prima è che le immagini sono quadrate, come tutte e quattro le immagini di sfondo di Kenney. La seconda è che le dimensioni del mondo visibile non cambieranno mai, altrimenti questo componente dovrebbe gestire gli eventi di ridimensionamento del gioco. La terza ipotesi è che la posizione (0,0)
si trovi al centro dello schermo. Queste ipotesi richiedono una configurazione specifica del CameraComponent
del gioco.
Crea un altro nuovo file, questa volta denominato game.dart
, sempre nella directory 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();
}
}
Qui succede di tutto. Inizia con il corso MyPhysicsGame
. A differenza del codelab precedente, questo estende Forge2DGame
e non FlameGame
. Forge2DGame
estende FlameGame
con alcune modifiche interessanti. La prima è che, per impostazione predefinita, zoom
è impostato su 10. Questa impostazione zoom
riguarda l'intervallo di valori utili con cui i motori di simulazione fisica in stile Box2D
funzionano bene. Il motore è scritto utilizzando il sistema MKS, in cui si presume che le unità siano in metri, chilogrammi e secondi. L'intervallo in cui non vengono visualizzati errori matematici evidenti per gli oggetti va da 0,1 metri a decine di metri. L'inserimento diretto delle dimensioni in pixel senza un certo livello di riduzione farebbe uscire Forge2D dall'area utile. Il riepilogo utile è pensare di simulare oggetti che vanno da una lattina di soda a un autobus.
Le ipotesi fatte nel componente Sfondo vengono soddisfatte qui fissando la risoluzione di CameraComponent
a 800 x 600 pixel virtuali. Ciò significa che l'area di gioco sarà larga 80 unità e alta 60 unità, con il centro in (0,0)
. Ciò non influisce sulla risoluzione visualizzata, ma influirà sulla posizione degli oggetti nella scena di gioco.
Accanto all'argomento del costruttore camera
è presente un altro argomento più in linea con la fisica chiamato gravity
. La gravità è impostata su Vector2
con un x
di 0
e un y
di 10
. 10
è un'approssimazione ravvicinata del valore generalmente accettato di 9,81 metri al secondo per secondo per la gravità. Il fatto che la gravità sia impostata su 10 positivo indica che in questo sistema la direzione dell'asse Y è verso il basso. È diverso da Box2D in generale, ma è in linea con la configurazione abituale di Flame.
Il metodo successivo è onLoad
. Questo metodo è asincrono, il che è appropriato perché è responsabile del caricamento degli asset immagine dal disco. Le chiamate a images.load
restituiscono un Future<Image>
e, come effetto collaterale, memorizzano nella cache l'immagine caricata nell'oggetto Game. Questi futures vengono riuniti e attesi come una singola unità utilizzando il metodo statico Futures.wait
. L'elenco delle immagini restituite viene poi associato a singoli nomi in base al pattern.
Le immagini dello spritesheet vengono poi inserite in una serie di oggetti XmlSpriteSheet
che si occupano di recuperare gli sprite denominati singolarmente contenuti nello spritesheet. La classe XmlSpriteSheet
è definita nel pacchetto flame_kenney_xml
.
Una volta completate queste operazioni, devi apportare solo un paio di modifiche minori a lib/main.dart
per visualizzare un'immagine sullo schermo.
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
}
Con questa modifica, ora puoi eseguire di nuovo il gioco per vedere lo sfondo sullo schermo. Tieni presente che l'istanza della videocamera CameraComponent.withFixedResolution()
aggiungerà le barre laterali come richiesto per far funzionare il rapporto 800 x 600 del gioco.
4. Aggiungi il suolo
Qualcosa su cui costruire
Se abbiamo la gravità, abbiamo bisogno di qualcosa per raccogliere gli oggetti nel gioco prima che cadano nella parte inferiore dello schermo. A meno che la caduta fuori dallo schermo non sia parte del design del gioco, ovviamente. Crea un nuovo file ground.dart
nella directory lib/components
e aggiungi quanto segue:
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),
),
],
);
}
Questo componente Ground
deriva da BodyComponent
. In Forge2D i corpi sono importanti, sono gli oggetti che fanno parte della simulazione fisica bidimensionale. Per l'elemento BodyDef
di questo componente è specificato un BodyType.static
.
In Forge2D, i corpi sono di tre tipi diversi. I corpi statici non si muovono. Hanno una massa nulla, quindi non reagiscono alla gravità, e una massa infinita, quindi non si muovono quando vengono colpiti da altri oggetti, indipendentemente dal loro peso. Questo rende i corpi statici perfetti per una superficie del suolo, in quanto non si muovono.
Gli altri due tipi di corpi sono cinematici e dinamici. I corpi dinamici sono corpi completamente simulati che reagiscono alla gravità e agli oggetti con cui urtano. Nel resto di questo codelab vedrai molti corpi dinamici. I corpi cinematici sono una via di mezzo tra statici e dinamici. Si muovono, ma non reagiscono alla gravità o agli altri oggetti che li colpiscono. Utile, ma non rientra nell'ambito di questo codelab.
Il corpo stesso non fa molto. Un corpo ha bisogno di forme associate per avere sostanza. In questo caso, questo corpo ha una forma associata, un PolygonShape
impostato come BoxXY
. Questo tipo di riquadro è allineato all'asse con il mondo, a differenza di un PolygonShape
impostato come BoxXY
che può essere ruotato attorno a un punto di rotazione. Ancora utile, ma anche al di fuori dello scopo di questo codelab. La forma e il corpo sono collegati con un supporto, utile per aggiungere al sistema elementi come friction
.
Per impostazione predefinita, un corpo esegue il rendering delle forme allegate in un modo utile per il debug, ma non ottimale per il gameplay. L'impostazione dell'argomento super
renderBody
su false
disattiva questo rendering di debug. È responsabilità del bambino SpriteComponent
fornire un rendering in-game di questo corpo.
Per aggiungere il componente Ground
al gioco, modifica il file game.dart
come segue.
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.
}
Questa modifica aggiunge una serie di componenti Ground
al mondo utilizzando un ciclo for
all'interno di un contesto List
e passando l'elenco risultante dei componenti Ground
al metodo addAll
di world
.
Ora, quando esegui il gioco, vengono visualizzati lo sfondo e il terreno.
5. Aggiungi i mattoni
Creare un muro
Il suolo ci ha fornito un esempio di corpo statico. Ora è il momento del primo componente dinamico. I componenti dinamici in Forge2D sono la pietra angolare dell'esperienza del giocatore, sono gli elementi che si muovono e interagiscono con il mondo che li circonda. In questo passaggio, introdurrai i mattoni, che verranno scelti in modo casuale per essere visualizzati sullo schermo in un cluster di mattoni. Vedrai che cadono e si scontrano tra loro.
I mattoni verranno creati dallo sprite sheet degli elementi. Se guardi la descrizione della sprite sheet in assets/spritesheet_elements.xml
, noterai che abbiamo un problema interessante. I nomi non sembrano essere molto utili. Sarebbe utile poter selezionare un mattone in base al tipo di materiale, alle dimensioni e alla quantità di danni. Per fortuna, un elfo disponibile ha dedicato del tempo a capire il pattern nella denominazione dei file e ha creato uno strumento per semplificare la procedura. Crea un nuovo file generate_brick_file_names.dart
nella directory bin
e aggiungi i seguenti contenuti:
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();
}
L'editor dovrebbe mostrarti un avviso o un errore relativo a una dipendenza mancante. Aggiungilo utilizzando il seguente comando:
flutter pub add equatable
Ora dovresti essere in grado di eseguire questo programma nel seguente modo:
$ 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', }, }; }
Questo strumento ha analizzato il file di descrizione della sprite sheet e lo ha convertito in codice Dart che possiamo utilizzare per selezionare il file immagine corretto per ogni mattone che vuoi visualizzare sullo schermo. Utile.
Crea il file brick.dart
con i seguenti contenuti:
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();
}
}
Ora puoi vedere come il codice Dart generato in precedenza è integrato in questo codice di base per selezionare rapidamente le immagini dei mattoni in base a materiale, dimensioni e condizioni. Se guardi oltre i enum
e al componente Brick
stesso, dovresti trovare che la maggior parte di questo codice sembra abbastanza familiare dal componente Ground
del passaggio precedente. Qui è presente uno stato mutabile per consentire il danneggiamento del mattone, anche se l'utilizzo di questa funzionalità è lasciato come esercizio per il lettore.
È ora di visualizzare i mattoncini sullo schermo. Modifica il file game.dart
come segue:
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.
}
Questo codice aggiuntivo è leggermente diverso da quello utilizzato per aggiungere i componenti Ground
. Questa volta i Brick
vengono aggiunti in un cluster casuale, nel tempo. Il metodo che aggiunge i Brick
a await
è un Future.delayed
, che è l'equivalente asincrono di una chiamata sleep()
. Tuttavia, c'è un secondo aspetto da considerare per far funzionare questa operazione: la chiamata a addBricks
nel metodo onLoad
non è await
. In caso contrario, il metodo onLoad
non verrà completato finché non saranno presenti tutti i blocchi sullo schermo. Inserire la chiamata a addBricks
in una chiamata a unawaited
fa felice lo strumento di linting e rende evidente la nostra intenzione ai programmatori futuri. L'attesa del ritorno di questo metodo non è intenzionale.
Esegui il gioco e vedrai dei mattoni che si scontrano tra loro e cadono a terra.
6. Aggiungi il giocatore
Lanciare alieni contro i mattoni
Guardare i mattoni che cadono è divertente le prime due volte, ma immagino che questo gioco sarà più divertente se diamo al giocatore un avatar che può usare per interagire con il mondo. Che ne dici di un alieno che può lanciare i mattoni?
Crea un nuovo file player.dart
nella directory lib/components
e aggiungi quanto segue:
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;
}
Si tratta di un passaggio successivo rispetto ai componenti Brick
del passaggio precedente. Questo componente Player
ha due componenti secondari, un SpriteComponent
che dovresti riconoscere e un CustomPainterComponent
nuovo. Il concetto CustomPainter
è di Flutter e ti consente di dipingere su una tela. Viene utilizzato qui per dare al giocatore un feedback su dove volerà l'alieno rotondo quando viene lanciato.
Come fa il giocatore ad avviare il lancio dell'alieno? Utilizzando un gesto di trascinamento, che il componente Player rileva con i callback DragCallbacks
. I più attenti avranno notato qualcos'altro.
I componenti Ground
erano oggetti statici, mentre i componenti Brick erano oggetti dinamici. Il player qui è una combinazione di entrambi. Il giocatore inizia in stato statico, in attesa che lo trascini, e al rilascio del trascinamento passa da statico a dinamico, aggiunge un impulso lineare proporzionale al trascinamento e lascia volare l'avatar alieno.
Nel componente Player
è presente anche del codice per rimuoverlo dallo schermo se esce dai limiti, entra in modalità di sospensione o scade il tempo di attesa. L'obiettivo è consentire al giocatore di lanciare l'alieno, vedere cosa succede e riprovare.
Integra il componente Player
nel gioco modificando game.dart
come segue:
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.
}
L'aggiunta del player al gioco è simile ai componenti precedenti, con un'ulteriore complicazione. L'alieno del giocatore è progettato per rimuoversi dal gioco in determinate condizioni, quindi è presente un gestore dell'aggiornamento che controlla se non è presente alcun componente Player
nel gioco e, in caso affermativo, ne aggiunge uno. L'esecuzione del gioco ha questo aspetto.
7. Reagire all'impatto
Aggiungi i nemici
Hai visto oggetti statici e dinamici che interagiscono tra loro. Tuttavia, per ottenere risultati, devi ricevere callback nel codice quando le cose entrano in collisione. Dovrai introdurre alcuni nemici contro cui il giocatore dovrà combattere. Questo fornisce un percorso per una condizione di vittoria: rimuovi tutti i nemici dal gioco.
Crea un file enemy.dart
nella directory lib/components
e aggiungi quanto segue:
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();
}
Dalle tue precedenti interazioni con i componenti Player e Brick, la maggior parte di questo file dovrebbe essere familiare. Tuttavia, nell'editor saranno presenti un paio di sottolineature rosse a causa di una nuova classe di base sconosciuta. Aggiungi subito questa classe aggiungendo un file denominato body_component_with_user_data.dart
a lib/components
con i seguenti contenuti:
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;
}
}
Questa classe di base, combinata con il nuovo callback beginContact
nel componente Enemy
, costituisce la base per ricevere notifiche in modo programmatico sugli impatti tra corpi. Infatti, dovrai modificare i componenti tra cui vuoi ricevere le notifiche relative all'impatto. Quindi, modifica i componenti Brick
, Ground
e Player
in modo da utilizzare questo BodyComponentWithUserData
al posto della classe di base BodyComponent
utilizzata da questi componenti. Ad esempio, ecco come modificare il componente 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),
),
],
);
}
Per ulteriori informazioni su come Forge2d gestisce i contatti, consulta la documentazione di Forge2D sui callback dei contatti.
Vincere la partita
Ora che hai nemici e un modo per rimuoverli dal mondo, esiste un modo semplice per trasformare questa simulazione in un gioco. Il tuo obiettivo è eliminare tutti i nemici. Ora è il momento di modificare il file game.dart
come segue:
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.
}
}
La tua sfida, se decidi di accettarla, è avviare il gioco e raggiungere questa schermata.
8. Complimenti
Congratulazioni, hai creato un gioco con Flutter e Flame.
Hai creato un gioco utilizzando il motore di gioco Flame 2D e lo hai incorporato in un wrapper Flutter. Hai utilizzato gli effetti di Flame per animare e rimuovere i componenti. Hai utilizzato i pacchetti Google Fonts e Flutter Animate per dare un aspetto ben progettato all'intero gioco.
Passaggi successivi
Dai un'occhiata ad alcuni di questi codelab…
- Creare UI di nuova generazione in Flutter
- Trasforma la tua app Flutter da noiosa a bella
- Aggiungere acquisti in-app all'app Flutter