1. Introduzione
Flame è un motore grafico 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 Fiamma per disegnare la mazza, la palla e i mattoni. Utilizzerai gli effetti di Fiamma per animare il movimento della mazza e scoprirai come integrare Flame con il sistema di gestione degli stati di Flutter.
Al termine, il gioco dovrebbe avere l'aspetto di 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 funziona Flame
Component
. Sono simili aiWidget
di Flutter. - Come gestire le collisioni.
- Come utilizzare
Effect
per animareComponent
. - Come sovrapporre
Widget
di Flutter su un gioco Flame. - Come integrare Flame con la gestione dello stato di Flutter.
Cosa creerai
In questo codelab, creerai un gioco in 2D con Flutter e Flame. Al termine, il tuo gioco dovrebbe soddisfare i seguenti requisiti
- Funziona su tutte e sei le piattaforme supportate da Flutter: Android, iOS, Linux, macOS, Windows e il web
- Mantieni almeno 60 FPS con il ciclo di gioco di Flame.
- Usa le funzionalità Flutter come il pacchetto
google_fonts
eflutter_animate
per ricreare l'atmosfera dei giochi arcade anni '80.
2. Configurare l'ambiente Flutter
Editor
Per semplificare questo codelab, si presuppone che il tuo ambiente di sviluppo sia Visual Studio Code (VS Code). VS Code è senza costi e funziona su tutte le principali piattaforme. Utilizziamo VS Code per questo codelab perché le istruzioni utilizzano per impostazione predefinita le scorciatoie specifiche di VS Code. Le attività diventano più semplici: "fai clic su questo pulsante" oppure "premi questo tasto per fare X" anziché "fare l'azione appropriata nell'editor per fare X".
Puoi utilizzare qualsiasi editor tu voglia: Android Studio, altri IDE IntelliJ, Emacs, Vim o Notepad++. Tutti funzionano con Flutter.
Scegli un target di sviluppo
Flutter produce app per più piattaforme. La tua app può essere eseguita su uno qualsiasi dei seguenti sistemi operativi:
- iOS
- Android
- Windows
- macOS
- Linux
- web
È pratica comune scegliere un sistema operativo come target di sviluppo. Si tratta del sistema operativo su cui viene eseguita la tua app durante lo sviluppo.
Ad esempio, supponiamo che tu stia utilizzando un laptop Windows per sviluppare l'app Flutter. Dopodiché 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. Potresti aver scelto Windows come destinazione di sviluppo, che esegue la tua app in fase di sviluppo come app Windows insieme al tuo editor.
Potresti avere la tentazione di scegliere il web come target per lo sviluppo. Questo presenta uno svantaggio durante lo sviluppo: perderai la funzionalità Stateful Hot Reload di Flutter. Al momento Flutter non può ricaricare le applicazioni web a caldo.
Scegli l'opzione che preferisci prima di continuare. Puoi sempre eseguire l'app su altri sistemi operativi in un secondo momento. La scelta di un target di sviluppo semplifica il passaggio successivo.
Installa Flutter
Le istruzioni più aggiornate sull'installazione dell'SDK Flutter sono disponibili all'indirizzo docs.flutter.dev.
Le istruzioni sul sito web di Flutter riguardano l'installazione dell'SDK, gli strumenti relativi ai target di sviluppo e i plug-in dell'editor. Per questo codelab, installa il seguente software:
- SDK Flutter
- Visual Studio Code con il plug-in Flutter
- Compilatore del software per il target di sviluppo scelto. (devi avere Visual Studio per scegliere Windows o Xcode come target per macOS o iOS)
Nella sezione successiva creerai il tuo primo progetto Flutter.
Se hai bisogno di risolvere dei problemi, potresti trovare utili alcune di queste domande e risposte (di StackOverflow).
Domande frequenti
- Come faccio a trovare il percorso dell'SDK Flutter?
- Cosa devo fare se il comando Flutter non viene trovato?
- Come faccio a risolvere il problema "In attesa di un altro comando flutter per rilasciare il blocco di avvio" problema?
- Come faccio a comunicare a Flutter dove si trova l'installazione dell'SDK Android?
- Come posso gestire l'errore Java durante l'esecuzione di
flutter doctor --android-licenses
? - Come faccio a gestire lo strumento Android
sdkmanager
non trovato? - Come faccio a gestire il problema "Manca il componente
cmdline-tools
" errore? - Come faccio a eseguire CocoaPods su Apple Silicon (M1)?
- Come faccio a disattivare la formattazione automatica al momento del salvataggio in VS Code?
3. Creare un progetto
Crea il tuo primo progetto Flutter
Dovrai aprire VS Code e creare il modello dell'app Flutter in una directory di tua scelta.
- Avvia Visual Studio Code.
- Apri la tavolozza dei comandi (
F1
,Ctrl+Shift+P
oShift+Cmd+P
), quindi digita "flutter new". Quando viene visualizzato, seleziona il comando Flutter: New Project.
- Seleziona Vuota applicazione. Scegli una directory in cui creare il progetto. Deve essere qualsiasi directory che non richiede privilegi elevati o non abbia uno spazio nel percorso. Alcuni esempi sono la tua home directory o
C:\src\
.
- Assegna al progetto il nome
brick_breaker
. Nella parte restante di questo codelab si presuppone che tu abbia assegnato alla tua app il nomebrick_breaker
.
Flutter ora crea la cartella del progetto e VS Code la apre. Ora sovrascriverai i contenuti di due file con uno scaffold di base dell'app.
Copia e Incolla l'app iniziale
In questo modo, il codice di esempio fornito in questo codelab viene aggiunto alla tua app.
- Nel riquadro sinistro di VS Code, fai clic su Explorer e apri il file
pubspec.yaml
.
- Sostituisci il contenuto di questo file con il seguente:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: 'none'
version: 0.1.0
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
flame: ^1.16.0
flutter:
sdk: flutter
flutter_animate: ^4.5.0
google_fonts: ^6.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
flutter:
uses-material-design: true
Il file pubspec.yaml
specifica le informazioni di base della tua app, come la versione attuale, le dipendenze e gli asset con cui verrà spedita.
- Apri il file
main.dart
nella directorylib/
.
- Sostituisci il contenuto di questo file con il seguente:
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 rendering del peggior videogioco al mondo è ora a 60 f/s.
4. Crea il gioco
Misura il gioco
Un gioco in due dimensioni (2D) richiede un'area di gioco. Creerai un'area di dimensioni specifiche che potrai utilizzare per definire le dimensioni di altri aspetti del gioco.
Esistono diversi 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 verso destra lungo l'asse x e verso l'alto lungo l'asse y. Questo standard si applica alla maggior parte dei giochi attuali, in particolare quando si tratta di giochi che prevedono tre dimensioni.
La convenzione in cui è stato creato il gioco Breakout originale era quella di impostare l'origine nell'angolo in alto a sinistra. La direzione x positiva è rimasta la stessa, ma la direzione y è stata invertita. La direzione x positiva per l'asse x era corretta, mentre la direzione 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 chiamata lib/src
. Questo file acquisirà più costanti nei passaggi successivi.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Le dimensioni del gioco saranno 820 pixel di larghezza per 1600 pixel di altezza. L'area di gioco viene ridimensionata per adattarsi alla finestra in cui viene visualizzata, ma tutti i componenti aggiunti allo schermo si adattano a questa altezza e larghezza.
Crea un'area giochi
Nel gioco Breakout, la palla rimbalza contro le pareti dell'area giochi. Per far fronte alle collisioni, è necessario prima un componente PlayArea
.
- Crea un file denominato
play_area.dart
in una nuova directory chiamatalib/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
, Fiamma ha Component
. Le app Flutter consistono nella creazione di alberi di widget, mentre i giochi di fiamme consistono nel mantenere gli alberi di componenti.
Qui c'è una differenza interessante tra Flutter e Flame. La struttura di widget di Flutter è una descrizione temporanea creata per essere utilizzata per aggiornare il livello RenderObject
permanente e modificabile. I componenti di Flame sono permanenti 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 inizia con il ciclo di gioco, descritto 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 contenuti seguenti.
lib/src/components/components.dart
export 'play_area.dart';
L'istruzione export
svolge il ruolo inverso di import
. Dichiara le funzionalità esposte da questo file quando viene importato in un altro file. Questo file aumenterà le voci man mano che aggiungi nuovi componenti nei passaggi successivi.
Crea un gioco Flame
Per spegnere gli scarafaggi rossi dal passaggio precedente, genera una nuova sottoclasse per FlameGame
di Fiamma.
- Crea un file denominato
brick_breaker.dart
inlib/src
e aggiungi il codice seguente.
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 il letterbox come richiesto.
Puoi mostrare la larghezza e l'altezza del gioco in modo che i componenti secondari, come PlayArea
, possano impostarsi sulle dimensioni appropriate.
Nel metodo con override onLoad
, il codice esegue due azioni.
- Consente di configurare la parte in alto a sinistra come ancoraggio del mirino. Per impostazione predefinita, il mirino utilizza la parte centrale dell'area come ancoraggio per
(0,0)
. - Aggiunge
PlayArea
aworld
. Il mondo rappresenta il mondo del gioco. Proietta tutti gli elementi secondari tramite la trasformazione della vista diCameraComponent
.
Scarica il gioco sullo schermo
Per vedere tutte le modifiche apportate in questo passaggio, aggiorna il file lib/main.dart
con le modifiche seguenti.
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 seguente figura.
Nel prossimo passaggio, aggiungerai una palla al mondo e la farai muovere!
5. Mostra la palla
Creare il componente palla
Per inserire una palla in movimento sullo schermo è necessario 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 definizione di costanti denominate come valori derivati restituirà molte volte in questo codelab. In questo modo puoi modificare i gameWidth
e le gameHeight
di primo livello per scoprire come l'aspetto del gioco cambia 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 il PlayArea
utilizzando il RectangleComponent
, quindi è ovvio che esistono più forme. CircleComponent
, come RectangleComponent
, deriva da PositionedComponent
, quindi puoi posizionare la palla sullo schermo. Ma soprattutto, la sua posizione può essere aggiornata.
Questo componente introduce il concetto di velocity
o il cambiamento di posizione nel tempo. La velocità è un oggetto Vector2
, in quanto la velocità è sia velocità che direzione. Per aggiornare la posizione, sostituisci il metodo update
, che il motore grafico chiama per ogni frame. dt
è la durata tra il frame precedente e questo frame. Ciò ti consente di adattarti a fattori quali frequenze fotogrammi diverse (60 Hz o 120 Hz) o frame lunghi a causa di calcoli eccessivi.
Presta particolare attenzione all'aggiornamento di position += velocity * dt
. Questo è il modo in cui implementi 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
come segue.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Aggiungere la palla al mondo
Hai una palla. Colloca il dispositivo nel mondo reale e lo collochiamo 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 innanzitutto dimezza la dimensione della partita, poiché Vector2
ha sovraccarichi dell'operatore (*
e /
) per scalare un Vector2
in base a un valore scalare.
Impostare il velocity
della palla richiede una maggiore complessità. L'intento è quello di far spostare la pallina verso il basso sullo schermo in una direzione casuale a una velocità ragionevole. La chiamata al metodo normalized
crea un oggetto Vector2
impostato nella stessa direzione dell'elemento Vector2
originale, ma ridimensionato a una distanza pari a 1. In questo modo la velocità della palla rimane costante, indipendentemente dalla direzione in cui si muove. La velocità della palla viene poi scalata fino a 1/4 dell'altezza della partita.
Per ottenere i vari valori corretti è necessario un processo di iterazione, noto anche come test di gioco nel settore.
L'ultima riga attiva la visualizzazione di debug, che aggiunge ulteriori informazioni al display per facilitare il debug.
A questo punto, il gioco dovrebbe essere simile alla seguente.
Sia il componente PlayArea
sia il componente Ball
hanno informazioni di debug, ma i mattini di sfondo ritagliano i numeri di PlayArea
. Sono visualizzate tutte le informazioni di debug perché hai attivato debugMode
per l'intera struttura ad albero dei componenti. Puoi anche attivare il debug solo per determinati componenti, se questo è più utile.
Se riavvii il gioco alcune volte, potresti notare che la palla non interagisce con le pareti come previsto. Per ottenere questo risultato, devi aggiungere il rilevamento delle collisioni, che utilizzerai nel passaggio successivo.
6. Rimbalzo qua e là
Aggiungi rilevamento delle collisioni
Il rilevamento delle collisioni aggiunge un comportamento in cui il gioco riconosce quando due oggetti vengono a contatto tra loro.
Per aggiungere il rilevamento delle collisioni al gioco, aggiungi il mix HasCollisionDetection
al gioco BrickBreaker
come mostrato nel seguente codice.
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;
}
}
Questa opzione tiene traccia delle hitbox dei componenti e attiva i callback di collisione a ogni tick di gioco.
Per iniziare a completare 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);
}
}
Se aggiungi un componente RectangleHitbox
come figlio di RectangleComponent
, verrà creata una casella di hit per il rilevamento delle collisioni corrispondente alle dimensioni del componente principale. Esiste un costruttore di fabbrica per RectangleHitbox
chiamato relative
per le volte in cui vuoi una hitbox più piccola o più grande rispetto al componente principale.
Rimbalzo la pallina
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 la 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.
In primo luogo, il codice verifica se Ball
è in conflitto con PlayArea
. Per il momento sembra ridondante, in quanto non ci sono altri componenti nel mondo del gioco. Questo cambierà nel passaggio successivo, quando aggiungerai una mazza al mondo. Inoltre, aggiunge anche una condizione else
da gestire quando la pallina colpisce con oggetti che non sono la mazza. Un breve promemoria per ricordarti di implementare la logica rimanente, se vuoi.
Quando la palla si scontra con la parete inferiore scompare dalla superficie di gioco mentre è ancora molto visibile. Gestisci questo artefatto in un passaggio successivo, usando il potere degli effetti della Fiamma.
Ora che la palla si scontra con le pareti del gioco, sarebbe utile dare al giocatore una mazza per colpirla con...
7. Metti la mazza sulla palla
Crea la mazza
Per aggiungere una mazza per mantenere la palla in gioco durante la partita,
- 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
sono autoesplicative. 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 l'ampiezza dei passi della mazza per ogni pressione dei tasti 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 Pipistrello è PositionComponent
, non RectangleComponent
né CircleComponent
. Questo codice deve eseguire il rendering di Bat
sullo schermo. A questo scopo, esegue l'override del callback render
.
Osservando attentamente la chiamata canvas.drawRRect
(disegna un rettangolo arrotondato), potresti chiederti "dove si trova il rettangolo?" Offset.zero & size.toSize()
sfrutta un sovraccarico di operator &
sulla classe Offset
dart:ui
che crea Rect
. Questa forma breve potrebbe confonderti all'inizio, ma la vedrai spesso nei codici Flutter e Flame di livello inferiore.
In secondo luogo, puoi trascinare questo componente Bat
con un dito o con il mouse, a seconda della piattaforma. Per implementare questa funzionalità, aggiungi il mix DragCallbacks
e sostituisci l'evento onDragUpdate
.
Infine, il componente Bat
deve rispondere al controllo da tastiera. La funzione moveBy
consente a un altro codice di indicare alla mazza di spostarsi verso sinistra o verso 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. È disponibile una raccolta di Effect
in Flame per eseguire una serie di effetti.
Gli argomenti del costruttore dell'effetto includono un riferimento al getter game
. Ecco perché includi il mix HasGameReference
in questo corso. Questo mixin aggiunge a questo componente una funzione di accesso game
sicura per il tipo per accedere all'istanza BrickBreaker
nella parte superiore della struttura ad albero dei componenti.
- Per rendere
Bat
disponibile 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';
Aggiungi la mazza al mondo
Per aggiungere il componente Bat
al mondo di gioco, aggiorna BrickBreaker
come segue.
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(Bat( // Add from here...
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
con override gestiscono l'input da tastiera. Ricorda il codice che hai aggiunto in precedenza per spostare la mazza del numero di passi appropriato.
La porzione rimanente di codice aggiunto aggiunge la mazza al mondo di gioco nella posizione appropriata e con le giuste proporzioni. Avendo tutte queste impostazioni esposte in questo file, semplifichi la tua capacità di modificare la dimensione relativa della mazza e della pallina per ottenere la sensazione di gioco giusta.
Se giochi a questo punto, vedrai che puoi muovere la mazza per intercettare la palla, ma non ricevi alcuna risposta visibile, a parte il logging di debug lasciato nel codice di rilevamento collisioni di Ball
.
È ora di risolvere il problema. 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'; // 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( // Modify from here...
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 { // To here.
debugPrint('collision with $other');
}
}
}
Queste modifiche al codice risolvono due problemi distinti.
In primo luogo, fissa la pallina che salta fuori dal momento in cui tocca la parte inferiore dello schermo. Per risolvere il problema, sostituisci la chiamata removeFromParent
con RemoveEffect
. La RemoveEffect
rimuove la palla dal mondo di gioco dopo averla lasciata uscire dall'area di gioco visibile.
In secondo luogo, queste modifiche migliorano la gestione degli scontri tra mazza e pallina. Questa gestione del codice è molto a favore del giocatore. Finché il giocatore tocca la palla con la mazza, la palla torna nella parte superiore dello schermo. Se ti sembra troppo permissivo e vuoi qualcosa di più realistico, modifica questa modalità di gestione per adattarla meglio a come vuoi che appaia il gioco.
Vale la pena sottolineare la complessità dell'aggiornamento di velocity
. Non inverte semplicemente il componente y
della velocità, come è stato fatto per le collisioni delle pareti. Inoltre, aggiorna il componente x
in modo che dipenda dalla posizione relativa della mazza e della pallina al momento del contatto. Ciò offre al giocatore un maggiore controllo su ciò che fa la palla, ma esattamente come non viene comunicato al giocatore in alcun modo se non attraverso il gioco.
Ora che hai una mazza con cui colpire la palla, sarebbe bello avere dei mattoncini da rompere con la pallina!
8. Abbatti il muro
Costruire i mattoni
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
come segue.
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, gran parte di questo codice dovrebbe essere familiare. Questo codice utilizza un'istruzione RectangleComponent
, con rilevamento delle collisioni e riferimento sicuro per tipo 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 interroga il mondo in cerca di mattoncini e conferma che ne rimane solo uno. Questo potrebbe creare confusione, perché la riga precedente rimuove questo mattoncino da quello principale.
Il punto chiave da capire è che la rimozione dei componenti è un comando in coda. Il mattoncino viene rimosso dopo l'esecuzione del codice, ma prima del prossimo segno di spunta nel mondo del gioco.
Per rendere il componente Brick
accessibile a BrickBreaker
, modifica lib/src/components/components.dart
come segue.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
Aggiungi 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.
}
}
}
Questo introduce l'unico aspetto nuovo, un modificatore di difficoltà che aumenta la velocità della palla dopo ogni collisione di mattoncini. Questo parametro regolabile deve essere testato per trovare la curva di difficoltà appropriata per il tuo gioco.
Modifica la partita di BrickBreaker
come segue.
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 così com'è, verranno visualizzate tutte le meccaniche principali del gioco. Potresti disattivare il debug e terminarlo, ma sembra che manchi qualcosa.
Che ne dici di una schermata di benvenuto, di una partita fuori schermo o di un punteggio? Flutter può aggiungere queste funzionalità al gioco ed è qui che rivolgerai la tua attenzione.
9. Vinci la partita
Aggiungi stati di riproduzione
In questo passaggio devi incorporare il gioco Fiamma all'interno di un wrapper Flutter e quindi aggiungere gli overlay Flutter per le schermate di benvenuto, game over e vinte.
Per prima cosa, modifica i file del gioco e dei componenti per implementare uno stato di riproduzione che indichi se mostrare o meno un overlay e, in caso affermativo, quale.
- Modifica il gioco
BrickBreaker
come segue.
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 modifica gran parte del gioco BrickBreaker
. L'aggiunta dell'enumerazione playState
richiede molto lavoro. Indica il punto in cui il giocatore entra, gioca, perde o vince la partita. Nella parte superiore del file, definisci l'enumerazione, quindi crei un'istanza come stato nascosto con getter e setter corrispondenti. Questi getter e setter consentono di modificare gli overlay quando le varie parti del gioco attivano le transizioni dello stato di gioco.
Successivamente, suddividi il codice in onLoad
in onLoad e in un nuovo metodo startGame
. Prima di questo cambiamento, potevi iniziare un nuovo gioco soltanto riavviandolo. Grazie a queste nuove aggiunte, il giocatore può iniziare un nuovo gioco 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 del tocco e hai esteso il gestore della tastiera per consentire all'utente di avviare una nuova partita in diverse modalità. Con lo stato di riproduzione modellato, avrebbe senso aggiornare i componenti per attivare le transizioni dello stato di riproduzione quando il player 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
. Questo approccio dovrebbe essere corretto se il giocatore consente alla pallina di uscire dal fondo dello schermo.
- Modifica il componente
Brick
come segue.
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.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
D'altra parte, se il giocatore riesce a rompere tutti i mattoncini, si è guadagnato una "partita vinta" schermo. Complimenti, giocatore, ben fatto!
Aggiungi il wrapper Flutter
Se vuoi incorporare il gioco e aggiungere overlay relativi allo stato di gioco, aggiungi la shell di Flutter.
- Crea una directory
widgets
inlib/src
. - Aggiungi un file
game_app.dart
e inserisci i seguenti contenuti al suo interno.
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(
useMaterial3: true,
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 build di widget Flutter standard. Le parti specifiche di Flame includono l'uso di GameWidget.controlled
per creare e gestire l'istanza del gioco BrickBreaker
e il nuovo argomento overlayBuilderMap
per GameWidget
.
Le chiavi di questo overlayBuilderMap
devono essere allineate agli overlay aggiunti o rimossi dal setter playState
in BrickBreaker
. Il tentativo di impostare un overlay che non è in questa mappa porta a volti infelici intorno a te.
- 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, nel gioco viene visualizzato l'output previsto. Se scegli come target macOS o Android, devi apportare un'ultima modifica per consentire la visualizzazione di google_fonts
.
Attivazione dell'accesso ai caratteri
Aggiungere l'autorizzazione di accesso a internet per Android
Per Android, devi aggiungere l'autorizzazione di accesso a internet. Modifica 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 dei diritti per macOS
Per macOS, hai due file da modificare.
- Modifica il file
DebugProfile.entitlements
in modo che corrisponda al codice seguente.
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 codice seguente
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>
Questa operazione dovrebbe mostrare una schermata di benvenuto e una schermata di game over o vinto su tutte le piattaforme. Questi schermi potrebbero essere un po' semplici e sarebbe bello avere un punteggio. Quindi, indovina cosa farai nel prossimo passaggio!
10. Mantieni punteggio
Aggiungi punteggio alla partita
In questo passaggio, esponi il punteggio del gioco al contesto di 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 mattoncino.
- Modifica il gioco
BrickBreaker
come segue.
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, lo stato del gioco viene associato 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>());
}
}
}
Crea un gioco bello
Ora che puoi tenere il punteggio in Flutter, è il momento di mettere insieme i widget per farlo stare bene.
- 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 codice seguente.
L'overlay rende gli overlay più nitidi grazie alla potenza del pacchetto flutter_animate
, che aggiunge movimento e stile alle schermate.
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 uno sguardo più approfondito sulla potenza di flutter_animate
, consulta il codelab Creazione di UI di nuova generazione 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 punteggi 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 vinte, 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(
useMaterial3: true,
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, ora dovresti essere in grado di eseguire il gioco su una qualsiasi delle sei piattaforme target 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 usando il motore di gioco Flame 2D e lo hai incorporato in un wrapper Flutter. Hai utilizzato gli effetti della fiamma per animare e rimuovere i componenti. Hai utilizzato i pacchetti Google Fonts e Flutter Animate per conferire un aspetto ottimale al gioco.
Passaggi successivi
Dai un'occhiata ad alcuni di questi codelab...
- Creazione di UI di nuova generazione in Flutter
- Trasforma la tua app Flutter da noiosa a bellissima
- Aggiungere acquisti in-app all'app Flutter