1. Introduzione
Flame è un motore di gioco 2D basato su Flutter. In questo codelab, creerai un gioco ispirato a uno dei classici dei videogiochi degli anni '70, Breakout di Steve Wozniak. Utilizzerai i componenti di Flame per disegnare la mazza, la palla e i mattoni. Utilizzerai gli effetti di Flame per animare il movimento del pipistrello e vedrai come integrare Flame con il sistema di gestione dello stato di Flutter.
Al termine, il gioco dovrebbe essere simile a questa GIF animata, anche se un po' più lento.
Obiettivi didattici
- Come funzionano le basi di Flame, a partire da
GameWidget
. - Come utilizzare un ciclo di gioco.
- Come funzionano i
Component
di Flame. Sono simili aiWidget
di Flutter. - Come gestire le collisioni.
- Come utilizzare i
Effect
per animare iComponent
. - Come sovrapporre le
Widget
di Flutter a un gioco Flame. - Come integrare Flame con la gestione dello stato di Flutter.
Cosa creerai
In questo codelab, creerai un gioco 2D utilizzando Flutter e Flame. Al termine, il gioco deve soddisfare i seguenti requisiti:
- Funzionare su tutte e sei le piattaforme supportate da Flutter: Android, iOS, Linux, macOS, Windows e web
- Mantieni almeno 60 fps utilizzando il ciclo di gioco di Flame.
- Utilizza le funzionalità di Flutter, come il pacchetto
google_fonts
eflutter_animate
, per ricreare l'atmosfera dei giochi arcade degli anni '80.
2. Configurare l'ambiente Flutter
Editor
Per semplificare questo codelab, si presume che Visual Studio Code (VS Code) sia il tuo ambiente di sviluppo. VS Code è senza costi e funziona su tutte le principali piattaforme. Utilizziamo VS Code per questo codelab perché le istruzioni utilizzano scorciatoie specifiche di VS Code per impostazione predefinita. Le attività diventano più semplici: "fai clic su questo pulsante" o "premi questo tasto per eseguire X" anziché "esegui l'azione appropriata nell'editor per eseguire X".
Puoi utilizzare qualsiasi editor: Android Studio, altri IDE IntelliJ, Emacs, Vim o Notepad++. Funzionano tutti con Flutter.
Scegliere un target di sviluppo
Flutter produce app per più piattaforme. La tua app può essere eseguita su uno dei seguenti sistemi operativi:
- iOS
- Android
- Windows
- macOS
- Linux
- web
È prassi comune scegliere un sistema operativo come target di sviluppo. Questo è il sistema operativo su cui viene eseguita la tua app durante lo sviluppo.
Ad esempio, supponiamo che tu stia utilizzando un laptop Windows per sviluppare la tua app Flutter. Quindi scegli Android come target di sviluppo. Per visualizzare l'anteprima dell'app, collega un dispositivo Android al laptop Windows con un cavo USB e l'app in fase di sviluppo viene eseguita sul dispositivo Android collegato o in un emulatore Android. Avresti potuto scegliere Windows come target di sviluppo, in modo da eseguire l'app in fase di sviluppo come app Windows insieme all'editor.
Fai la tua scelta prima di continuare. Puoi sempre eseguire la tua app su altri sistemi operativi in un secondo momento. La scelta di un target di sviluppo semplifica il passaggio successivo.
Installare Flutter
Le istruzioni più aggiornate sull'installazione dell'SDK Flutter sono disponibili su docs.flutter.dev.
Le istruzioni sul sito web di Flutter riguardano l'installazione dell'SDK e degli strumenti correlati alla destinazione di sviluppo, nonché i plug-in dell'editor. Per questo codelab, installa il seguente software:
- SDK Flutter
- Visual Studio Code con il plug-in Flutter
- Software di compilazione per la destinazione di sviluppo scelta. (Devi utilizzare Visual Studio per Windows o Xcode per macOS o iOS)
Nella sezione successiva creerai il tuo primo progetto Flutter.
Se devi risolvere eventuali problemi, potresti trovare utili alcune di queste domande e risposte (di StackOverflow).
Domande frequenti
- Come faccio a trovare il percorso dell'SDK Flutter?
- Cosa faccio quando il comando Flutter non viene trovato?
- Come faccio a risolvere il problema "In attesa che un altro comando Flutter rilasci il blocco di avvio"?
- Come faccio a indicare a Flutter la posizione dell'installazione dell'SDK Android?
- Come faccio a risolvere l'errore Java durante l'esecuzione di
flutter doctor --android-licenses
? - Come faccio a risolvere il problema relativo allo strumento Android
sdkmanager
non trovato? - Come faccio a risolvere l'errore "Il componente
cmdline-tools
non è presente"? - Come faccio a eseguire CocoaPods su Apple Silicon (M1)?
- Come faccio a disattivare la formattazione automatica al salvataggio in VS Code?
3. Crea un progetto
Crea il tuo primo progetto Flutter
Ciò comporta l'apertura di VS Code e la creazione del modello di app Flutter in una directory a tua scelta.
- Avvia Visual Studio Code.
- Apri la tavolozza dei comandi (
F1
oCtrl+Shift+P
oShift+Cmd+P
), quindi digita "flutter new". Quando viene visualizzato, seleziona il comando Flutter: New Project.
- Seleziona Empty Application (Applicazione vuota). Scegli una directory in cui creare il progetto. Deve essere una directory che non richiede privilegi elevati o che non contiene spazi nel percorso. Ad esempio, la tua directory home o
C:\src\
.
- Assegna un nome al progetto
brick_breaker
. Il resto di questo codelab presuppone che tu abbia chiamato la tua appbrick_breaker
.
Flutter ora crea la cartella del progetto e VS Code la apre. Ora sovrascriverai i contenuti di due file con una struttura di base dell'app.
Copiare e incollare l'app iniziale
In questo modo, il codice di esempio fornito in questo codelab viene aggiunto alla tua app.
- Nel riquadro a sinistra di VS Code, fai clic su Explorer e apri il file
pubspec.yaml
.
- Sostituisci i contenuti di questo file con i seguenti:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.8.0
dependencies:
flutter:
sdk: flutter
flame: ^1.28.1
flutter_animate: ^4.5.2
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
Il file pubspec.yaml
specifica le informazioni di base sulla tua app, come la versione attuale, le dipendenze e gli asset con cui verrà distribuita.
- Apri il file
main.dart
nella directorylib/
.
- Sostituisci i contenuti di questo file con i seguenti:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- Esegui questo codice per verificare che tutto funzioni. Dovrebbe essere visualizzata una nuova finestra con solo uno sfondo nero vuoto. Il peggior videogioco del mondo ora viene renderizzato a 60 FPS.
4. Creare il gioco
Valutare la partita
Un gioco bidimensionale (2D) ha bisogno di un'area di gioco. Costruirai un'area di dimensioni specifiche e poi le utilizzerai per dimensionare altri aspetti del gioco.
Esistono vari modi per disporre le coordinate nell'area di gioco. Secondo una convenzione, puoi misurare la direzione dal centro dello schermo con l'origine (0,0)
al centro dello schermo, i valori positivi spostano gli elementi a destra lungo l'asse x e verso l'alto lungo l'asse y. Questo standard si applica alla maggior parte dei giochi attuali, soprattutto a quelli che coinvolgono tre dimensioni.
Quando è stato creato il gioco Breakout originale, la convenzione prevedeva di impostare l'origine nell'angolo in alto a sinistra. La direzione x positiva è rimasta invariata, mentre y è stata invertita. La direzione x positiva era a destra e y era in basso. Per rimanere fedele all'epoca, questo gioco imposta l'origine nell'angolo in alto a sinistra.
Crea un file denominato config.dart
in una nuova directory denominata lib/src
. Questo file acquisirà altre costanti nei passaggi successivi.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Questo gioco avrà una larghezza di 820 pixel e un'altezza di 1600 pixel. L'area di gioco viene scalata per adattarsi alla finestra in cui viene visualizzata, ma tutti i componenti aggiunti allo schermo rispettano questa altezza e larghezza.
Creare un'area di gioco
Nel gioco Breakout, la palla rimbalza sulle pareti dell'area di gioco. Per gestire le collisioni, devi prima avere un componente PlayArea
.
- Crea un file denominato
play_area.dart
in una nuova directory denominatalib/src/components
. - Aggiungi quanto segue a questo file.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Dove Flutter ha Widget
, Flame ha Component
. Mentre le app Flutter consistono nella creazione di alberi di widget, i giochi Flame consistono nel mantenimento di alberi di componenti.
Qui sta una differenza interessante tra Flutter e Flame. L'albero dei widget di Flutter è una descrizione effimera creata per essere utilizzata per aggiornare il livello RenderObject
persistente e modificabile. I componenti di Flame sono persistenti e modificabili, con l'aspettativa che lo sviluppatore li utilizzi come parte di un sistema di simulazione.
I componenti di Flame sono ottimizzati per esprimere le meccaniche di gioco. Questo codelab inizierà con il ciclo di gioco, illustrato nel passaggio successivo.
- Per controllare il disordine, aggiungi un file contenente tutti i componenti di questo progetto. Crea un file
components.dart
inlib/src/components
e aggiungi i seguenti contenuti.
lib/src/components/components.dart
export 'play_area.dart';
L'istruzione export
svolge il ruolo inverso di import
. Dichiara la funzionalità esposta da questo file quando viene importato in un altro file. Questo file aumenterà il numero di voci man mano che aggiungi nuovi componenti nei passaggi successivi.
Creare un gioco Flame
Per eliminare le linee rosse del passaggio precedente, deriva una nuova sottoclasse per FlameGame
di Flame.
- Crea un file denominato
brick_breaker.dart
inlib/src
e aggiungi il seguente codice.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
Questo file coordina le azioni del gioco. Durante la creazione dell'istanza di gioco, questo codice configura il gioco in modo che utilizzi il rendering a risoluzione fissa. Il gioco viene ridimensionato per riempire lo schermo che lo contiene e aggiunge letterbox in base alle necessità.
Espone la larghezza e l'altezza del gioco in modo che i componenti secondari, come PlayArea
, possano impostarsi sulla dimensione appropriata.
Nel metodo sottoposto a override onLoad
, il codice esegue due azioni.
- Configura l'angolo in alto a sinistra come punto di ancoraggio per il mirino. Per impostazione predefinita,
viewfinder
utilizza il centro dell'area come punto di ancoraggio per(0,0)
. - Aggiunge
PlayArea
aworld
. Il mondo rappresenta il mondo di gioco. Proietta tutti i suoi figli attraverso la trasformazione della visualizzazioneCameraComponent
.
Visualizzare la partita sullo schermo
Per visualizzare tutte le modifiche apportate in questo passaggio, aggiorna il file lib/main.dart
con le seguenti modifiche.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
Dopo aver apportato queste modifiche, riavvia il gioco. Il gioco dovrebbe essere simile alla figura seguente.
Nel passaggio successivo, aggiungerai una palla al mondo e la farai muovere.
5. Visualizzare la palla
Crea il componente della palla
Per inserire una palla in movimento sullo schermo, devi creare un altro componente e aggiungerlo al mondo di gioco.
- Modifica i contenuti del file
lib/src/config.dart
come segue.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
Il pattern di progettazione per la definizione di costanti denominate come valori derivati verrà ripetuto più volte in questo codelab. In questo modo puoi modificare gameWidth
e gameHeight
di primo livello per esplorare come cambia l'aspetto del gioco di conseguenza.
- Crea il componente
Ball
in un file denominatoball.dart
inlib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
In precedenza hai definito PlayArea
utilizzando RectangleComponent
, quindi è logico che esistano altre forme. CircleComponent
, come RectangleComponent
, deriva da PositionedComponent
, quindi puoi posizionare la palla sullo schermo. Ancora più importante, la sua posizione può essere aggiornata.
Questo componente introduce il concetto di velocity
, ovvero il cambiamento di posizione nel tempo. La velocità è un oggetto Vector2
in quanto velocità e direzione. Per aggiornare la posizione, esegui l'override del metodo update
, che il motore grafico chiama per ogni frame. dt
è la durata tra il frame precedente e questo frame. In questo modo, puoi adattarti a fattori come frame rate diversi (60 Hz o 120 Hz) o frame lunghi dovuti a un numero eccessivo di calcoli.
Presta molta attenzione all'aggiornamento di position += velocity * dt
. Ecco come implementare l'aggiornamento di una simulazione discreta del movimento nel tempo.
- Per includere il componente
Ball
nell'elenco dei componenti, modifica il filelib/src/components/components.dart
nel seguente modo.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Aggiungi la palla al mondo
Hai una palla. Posizionalo nel mondo e configuralo per spostarsi nell'area di gioco.
Modifica il file lib/src/brick_breaker.dart
come segue.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true; // To here.
}
}
Questa modifica aggiunge il componente Ball
a world
. Per impostare il position
della palla al centro dell'area di visualizzazione, il codice dimezza innanzitutto le dimensioni del gioco, poiché Vector2
ha sovraccarichi degli operatori (*
e /
) per scalare un Vector2
in base a un valore scalare.
Impostare velocity
della palla è più complesso. L'obiettivo è spostare la palla verso il basso dello schermo in una direzione casuale a una velocità ragionevole. La chiamata al metodo normalized
crea un oggetto Vector2
impostato nella stessa direzione dell'oggetto Vector2
originale, ma ridimensionato a una distanza di 1. In questo modo, la velocità della palla rimane costante indipendentemente dalla direzione in cui va. La velocità della palla viene quindi aumentata fino a raggiungere 1/4 dell'altezza del gioco.
Per ottenere i valori corretti è necessario eseguire alcune iterazioni, note anche come playtest nel settore.
L'ultima riga attiva la visualizzazione di debug, che aggiunge informazioni aggiuntive alla visualizzazione per facilitare il debug.
Quando esegui il gioco, dovrebbe essere simile alla seguente visualizzazione.
Sia il componente PlayArea
che il componente Ball
contengono informazioni di debug, ma le maschere di sfondo ritagliano i numeri di PlayArea
. Il motivo per cui vengono visualizzate informazioni di debug è che hai attivato debugMode
per l'intero albero dei componenti. Se lo ritieni più utile, puoi anche attivare il debug solo per i componenti selezionati.
Se riavvii il gioco più volte, potresti notare che la palla non interagisce con le pareti come previsto. Per ottenere questo effetto, devi aggiungere il rilevamento delle collisioni, che farai nel passaggio successivo.
6. Saltellare
Aggiungere il rilevamento delle collisioni
Il rilevamento delle collisioni aggiunge un comportamento in cui il gioco riconosce quando due oggetti sono entrati in contatto tra loro.
Per aggiungere il rilevamento delle collisioni al gioco, aggiungi il mixin HasCollisionDetection
al gioco BrickBreaker
come mostrato nel codice seguente.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true;
}
}
Questo monitora le hitbox dei componenti e attiva i callback di collisione a ogni tick di gioco.
Per iniziare a popolare le hitbox del gioco, modifica il componente PlayArea
come mostrato di seguito:
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
L'aggiunta di un componente RectangleHitbox
come elemento secondario di RectangleComponent
creerà un riquadro di selezione per il rilevamento delle collisioni che corrisponde alle dimensioni del componente principale. Esiste un costruttore di fabbrica per RectangleHitbox
chiamato relative
per i casi in cui vuoi una hitbox più piccola o più grande del componente principale.
Far rimbalzare la palla
Finora, l'aggiunta del rilevamento delle collisioni non ha fatto alcuna differenza nel gameplay. Cambia quando modifichi il componente Ball
. È il comportamento della palla che deve cambiare quando entra in collisione con PlayArea
.
Modifica il componente Ball
come segue.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
Questo esempio apporta una modifica importante con l'aggiunta del callback onCollisionStart
. Il sistema di rilevamento delle collisioni aggiunto a BrickBreaker
nell'esempio precedente chiama questo callback.
Innanzitutto, il codice verifica se Ball
è entrato in collisione con PlayArea
. Per ora sembra ridondante, in quanto non ci sono altri componenti nel mondo di gioco. Questo cambierà nel passaggio successivo, quando aggiungerai una mazza al mondo. Poi, aggiunge anche una condizione else
per gestire i casi in cui la palla entra in collisione con oggetti diversi dalla mazza. Un promemoria amichevole per implementare la logica rimanente, se vuoi.
Quando la palla si scontra con la parete inferiore, scompare dalla superficie di gioco, ma rimane comunque ben visibile. Gestirai questo artefatto in un passaggio successivo, utilizzando la potenza degli effetti di Flame.
Ora che la palla entra in collisione con le pareti del gioco, sarebbe sicuramente utile dare al giocatore una mazza per colpirla…
7. Colpire la palla con la mazza
Crea il bat
Per aggiungere una mazza per mantenere la palla in gioco,
- Inserisci alcune costanti nel file
lib/src/config.dart
come segue.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
Le costanti batHeight
e batWidth
non richiedono spiegazioni. La costante batStep
, invece, richiede una spiegazione. Per interagire con la palla in questo gioco, il giocatore può trascinare la mazza con il mouse o il dito, a seconda della piattaforma, oppure utilizzare la tastiera. La costante batStep
configura la distanza percorsa dal pipistrello a ogni pressione del tasto Freccia sinistra o Freccia destra.
- Definisci la classe del componente
Bat
come segue.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
_paint,
);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(
MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
),
);
}
}
Questo componente introduce alcune nuove funzionalità.
Innanzitutto, il componente Bat è un PositionComponent
, non un RectangleComponent
né un CircleComponent
. Ciò significa che questo codice deve visualizzare Bat
sullo schermo. Per farlo, esegue l'override del callback render
.
Se osservi attentamente la chiamata canvas.drawRRect
(disegna un rettangolo arrotondato), potresti chiederti: "Dov'è il rettangolo?" Offset.zero & size.toSize()
sfrutta un sovraccarico di operator &
nella classe dart:ui
Offset
che crea Rect
. Questa abbreviazione potrebbe confonderti all'inizio, ma la vedrai spesso nel codice Flutter e Flame di livello inferiore.
In secondo luogo, questo componente Bat
è trascinabile con il dito o il mouse, a seconda della piattaforma. Per implementare questa funzionalità, aggiungi il mixin DragCallbacks
ed esegui l'override dell'evento onDragUpdate
.
Infine, il componente Bat
deve rispondere al controllo da tastiera. La funzione moveBy
consente ad altro codice di indicare a questo pipistrello di spostarsi a sinistra o a destra di un determinato numero di pixel virtuali. Questa funzione introduce una nuova funzionalità del motore di gioco Flame: Effect
s. Se aggiungi l'oggetto MoveToEffect
come elemento secondario di questo componente, il giocatore vede la mazza animata in una nuova posizione. In Flame è disponibile una raccolta di Effect
per eseguire una serie di effetti.
Gli argomenti del costruttore di Effect includono un riferimento al getter game
. Per questo motivo includi il mixin HasGameReference
in questa classe. Questo mixin aggiunge un accessor game
type-safe a questo componente per accedere all'istanza BrickBreaker
nella parte superiore dell'albero dei componenti.
- Per rendere disponibile
Bat
perBrickBreaker
, aggiorna il filelib/src/components/components.dart
come segue.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
Aggiungere la mazza al mondo
Per aggiungere il componente Bat
al mondo di gioco, aggiorna BrickBreaker
nel seguente modo.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add( // Add from here...
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
); // To here.
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here.
}
L'aggiunta del mixin KeyboardEvents
e del metodo onKeyEvent
sottoposto a override gestiscono l'input da tastiera. Recupera il codice aggiunto in precedenza per spostare la mazza della quantità di passi appropriata.
Il blocco di codice rimanente aggiunge il pipistrello al mondo di gioco nella posizione appropriata e con le giuste proporzioni. La presenza di tutte queste impostazioni in questo file semplifica la possibilità di modificare le dimensioni relative della mazza e della palla per ottenere la giusta sensazione di gioco.
Se giochi a questo punto, vedrai che puoi spostare la mazza per intercettare la palla, ma non riceverai alcuna risposta visibile, a parte la registrazione di debug che hai lasciato nel codice di rilevamento delle collisioni di Ball
.
È il momento di risolvere il problema. Modifica il componente Ball
nel seguente modo.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35)); // Modify from here...
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
Queste modifiche al codice risolvono due problemi distinti.
Innanzitutto, risolve il problema della scomparsa della palla nel momento in cui tocca la parte inferiore dello schermo. Per risolvere il problema, sostituisci la chiamata removeFromParent
con RemoveEffect
. Il RemoveEffect
rimuove la palla dal mondo di gioco dopo averla lasciata uscire dall'area di gioco visualizzabile.
In secondo luogo, queste modifiche correggono la gestione della collisione tra mazza e palla. Questo codice di gestione è molto favorevole al giocatore. Se il giocatore tocca la palla con la mazza, la palla torna nella parte superiore dello schermo. Se ti sembra troppo indulgente e vuoi qualcosa di più realistico, modifica la manovrabilità per adattarla meglio a come vuoi che sia il tuo gioco.
È importante sottolineare la complessità dell'aggiornamento di velocity
. Non inverte solo la componente y
della velocità, come avveniva per le collisioni con le pareti. Aggiorna anche il componente x
in modo che dipenda dalla posizione relativa della mazza e della palla al momento del contatto. In questo modo, il giocatore ha un maggiore controllo su ciò che fa la palla, ma il modo esatto non viene comunicato in alcun modo al giocatore, se non tramite il gioco.
Ora che hai una mazza con cui colpire la palla, sarebbe bello avere dei mattoni da rompere con la palla.
8. Break down the wall
Crea i mattoncini
Per aggiungere mattoncini al gioco:
- Inserisci alcune costanti nel file
lib/src/config.dart
come segue.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- Inserisci il componente
Brick
nel seguente modo.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
A questo punto, la maggior parte di questo codice dovrebbe esserti familiare. Questo codice utilizza un RectangleComponent
, con rilevamento delle collisioni e un riferimento type-safe al gioco BrickBreaker
nella parte superiore dell'albero dei componenti.
Il nuovo concetto più importante introdotto da questo codice è il modo in cui il giocatore raggiunge la condizione di vittoria. Il controllo delle condizioni di vittoria esegue una query nel mondo per i mattoncini e conferma che ne rimane solo uno. Potrebbe essere un po' complicato, perché la riga precedente rimuove questo blocco dal relativo elemento principale.
Il punto fondamentale da capire è che la rimozione dei componenti è un comando in coda. Rimuove il blocco dopo l'esecuzione di questo codice, ma prima del successivo tick del mondo di gioco.
Per rendere il componente Brick
accessibile a BrickBreaker
, modifica lib/src/components/components.dart
nel seguente modo.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
Aggiungere mattoncini al mondo
Aggiorna il componente Ball
come segue.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(RemoveEffect(delay: 0.35));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier); // To here.
}
}
}
Viene introdotto l'unico nuovo aspetto, un modificatore di difficoltà che aumenta la velocità della palla dopo ogni collisione con un mattone. Questo parametro regolabile deve essere testato per trovare la curva di difficoltà appropriata per il tuo gioco.
Modifica il gioco BrickBreaker
nel seguente modo.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
Se esegui il gioco, vengono visualizzate tutte le meccaniche di gioco principali. Potresti disattivare il debug e considerare il problema risolto, ma sembra che manchi qualcosa.
Che ne dici di una schermata di benvenuto, una schermata di game over e magari un punteggio? Flutter può aggiungere queste funzionalità al gioco, quindi è qui che dovrai concentrare la tua attenzione.
9. Vincere la partita
Aggiungere stati di riproduzione
In questo passaggio, incorpori il gioco Flame all'interno di un wrapper Flutter e poi aggiungi overlay Flutter per le schermate di benvenuto, fine partita e vittoria.
Innanzitutto, modifichi i file di gioco e dei componenti per implementare uno stato di riproduzione che rifletta se mostrare una sovrapposizione e, in caso affermativo, quale.
- Modifica il gioco
BrickBreaker
nel seguente modo.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
Questo codice cambia gran parte del gioco BrickBreaker
. L'aggiunta dell'enumerazione playState
richiede molto lavoro. Viene acquisito il punto in cui il giocatore si trova durante l'inserimento, la partita e la sconfitta o la vittoria. Nella parte superiore del file, definisci l'enumerazione, quindi la istanzi come stato nascosto con getter e setter corrispondenti. Questi getter e setter consentono di modificare le sovrapposizioni quando le varie parti del gioco attivano le transizioni dello stato di riproduzione.
Successivamente, dividi il codice in onLoad
in onLoad e in un nuovo metodo startGame
. Prima di questa modifica, potevi iniziare una nuova partita solo riavviando il gioco. Con queste nuove aggiunte, il giocatore può ora iniziare una nuova partita senza misure così drastiche.
Per consentire al giocatore di iniziare una nuova partita, hai configurato due nuovi gestori per il gioco. Hai aggiunto un gestore di tocco ed esteso il gestore della tastiera per consentire all'utente di iniziare una nuova partita in più modalità. Con lo stato di gioco modellato, avrebbe senso aggiornare i componenti per attivare le transizioni dello stato di gioco quando il giocatore vince o perde.
- Modifica il componente
Ball
come segue.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(
RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
},
),
); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
Questa piccola modifica aggiunge un callback onComplete
a RemoveEffect
che attiva lo stato di riproduzione gameOver
. Dovrebbe essere corretto se il giocatore lascia uscire la palla dalla parte inferiore dello schermo.
- Modifica il componente
Brick
nel seguente modo.
lib/src/components/brick.dart
impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Se invece il giocatore riesce a rompere tutti i mattoni, viene visualizzata la schermata "Partita vinta". Ben fatto, giocatore, ben fatto!
Aggiungi il wrapper Flutter
Per fornire un punto in cui incorporare il gioco e aggiungere overlay dello stato di gioco, aggiungi la shell Flutter.
- Crea una directory
widgets
inlib/src
. - Aggiungi un file
game_app.dart
e inserisci i seguenti contenuti.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style: Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
La maggior parte dei contenuti di questo file segue una creazione standard dell'albero dei widget di Flutter. Le parti specifiche di Flame includono l'utilizzo di GameWidget.controlled
per creare e gestire l'istanza di gioco BrickBreaker
e il nuovo argomento overlayBuilderMap
per GameWidget
.
Le chiavi di questo overlayBuilderMap
devono essere allineate alle sovrapposizioni aggiunte o rimosse dall'impostatore playState
in BrickBreaker
. Il tentativo di impostare una sovrapposizione non presente in questa mappa porta a faccine tristi ovunque.
- Per visualizzare questa nuova funzionalità sullo schermo, sostituisci il file
lib/main.dart
con i seguenti contenuti.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
Se esegui questo codice su iOS, Linux, Windows o sul web, l'output previsto viene visualizzato nel gioco. Se scegli come target macOS o Android, devi apportare un'ultima modifica per attivare la visualizzazione di google_fonts
.
Abilitare l'accesso ai caratteri
Aggiungere l'autorizzazione internet per Android
Per Android, devi aggiungere l'autorizzazione Internet. Modifica il tuo AndroidManifest.xml
come segue.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
Modificare i file di diritti per macOS
Per macOS, devi modificare due file.
- Modifica il file
DebugProfile.entitlements
in modo che corrisponda al seguente codice.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- Modifica il file
Release.entitlements
in modo che corrisponda al seguente codice
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
Se lo esegui così com'è, dovrebbe visualizzare una schermata di benvenuto e una schermata di fine partita o di vittoria su tutte le piattaforme. Queste schermate potrebbero essere un po' semplicistiche e sarebbe bello avere un punteggio. Indovina cosa farai nel passaggio successivo.
10. Tenere il punteggio
Aggiungere il punteggio alla partita
In questo passaggio, esponi il punteggio della partita al contesto Flutter circostante. In questo passaggio esponi lo stato del gioco Flame alla gestione dello stato di Flutter circostante. In questo modo, il codice del gioco può aggiornare il punteggio ogni volta che il giocatore rompe un blocco.
- Modifica il gioco
BrickBreaker
nel seguente modo.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState;
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
Se aggiungi score
al gioco, colleghi lo stato del gioco alla gestione dello stato di Flutter.
- Modifica la classe
Brick
per aggiungere un punto al punteggio quando il giocatore rompe i mattoncini.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({required super.position, required Color color})
: super(
size: Vector2(brickWidth, brickHeight),
anchor: Anchor.center,
paint: Paint()
..color = color
..style = PaintingStyle.fill,
children: [RectangleHitbox()],
);
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
removeFromParent();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Creare un gioco accattivante
Ora che puoi tenere il punteggio in Flutter, è il momento di assemblare i widget per renderlo accattivante.
- Crea
score_card.dart
inlib/src/widgets
e aggiungi quanto segue.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({super.key, required this.score});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- Crea
overlay_screen.dart
inlib/src/widgets
e aggiungi il seguente codice.
In questo modo, gli overlay vengono perfezionati utilizzando la potenza del pacchetto flutter_animate
per aggiungere movimento e stile alle schermate di overlay.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({super.key, required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
Per scoprire in modo più approfondito la potenza di flutter_animate
, consulta il codelab Building next generation UIs in Flutter.
Questo codice è cambiato molto nel componente GameApp
. Innanzitutto, per consentire a ScoreCard
di accedere a score
, devi convertirlo da StatelessWidget
a StatefulWidget
. L'aggiunta della scheda del punteggio richiede l'aggiunta di un Column
per impilare il punteggio sopra la partita.
In secondo luogo, per migliorare le esperienze di benvenuto, game over e vittoria, hai aggiunto il nuovo widget OverlayScreen
.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
Con tutto questo a posto, ora dovresti essere in grado di eseguire questo gioco su una qualsiasi delle sei piattaforme di destinazione Flutter. Il gioco dovrebbe essere simile al seguente.
11. Complimenti
Congratulazioni, sei riuscito a creare un gioco con Flutter e Flame.
Hai creato un gioco utilizzando il motore di gioco 2D Flame 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 bellissima
- Aggiungere acquisti in-app alla tua app Flutter