היכרות עם להבה עם Flutter

מבוא ל-Flame עם Flutter

מידע על Codelab זה

subjectהעדכון האחרון: מאי 20, 2025
account_circleנכתב על ידי Brett Morgan

1.‏ מבוא

Flame הוא מנוע משחקים דו-ממדי שמבוסס על Flutter. בקודלאב הזה תלמדו ליצור משחק בהשראת אחד ממשחקי הווידאו הקלאסיים של שנות ה-70, Breakout של סטיב ווזניאק. כדי לצייר את המחבט, הכדור והלבנים, תשתמשו ברכיבים של Flame. נשתמש באפקטים של Flame כדי ליצור אנימציה לתנועת העטלף, ונראה איך לשלב את Flame עם מערכת ניהול המצבים של Flutter.

בסיום, המשחק אמור להיראות כמו קובץ ה-GIF האנימטי הזה, אם כי מעט איטי יותר.

הקלטת מסך של משחק שבו אתם משחקים. המשחק התקדם מהר יותר באופן משמעותי.

מה תלמדו

  • הסבר על העקרונות הבסיסיים של Flame, החל מ-GameWidget.
  • איך משתמשים בלולאת משחק.
  • איך פועלים Component של Flame. הם דומים ל-Widget של Flutter.
  • איך מטפלים בהתנגשויות.
  • איך משתמשים ב-Effect כדי ליצור אנימציה ל-Component.
  • איך להוסיף שכבת-על של Widget ב-Flutter למשחק ב-Flame.
  • איך לשלב את Flame עם ניהול המצב של Flutter.

מה תפַתחו

בשיעור ה-Codelab הזה תלמדו ליצור משחק דו-מימדי באמצעות Flutter ו-Flame. בסיום, המשחק צריך לעמוד בדרישות הבאות:

  • הפעולה מתבצעת בכל שש הפלטפורמות שתומכות ב-Flutter: Android,‏ iOS,‏ Linux,‏ macOS,‏ Windows והאינטרנט
  • לשמור על קצב של 60fps לפחות באמצעות לולאת המשחק של Flame.
  • אתם יכולים להשתמש ביכולות של Flutter, כמו החבילה google_fonts ו-flutter_animate, כדי ליצור מחדש את האווירה של משחקי ארקייד משנות ה-80.

2.‏ הגדרת סביבת Flutter

הרשאת עריכה

כדי לפשט את הקודלאב הזה, נניח ש-Visual Studio Code (VS Code) היא סביבת הפיתוח שלכם. VS Code הוא כלי חינמי שפועל בכל הפלטפורמות העיקריות. אנחנו משתמשים ב-VS Code בסדנת הקוד הזו כי ההוראות מוגדרות כברירת מחדל למקשי קיצור ספציפיים ל-VS Code. המשימות הופכות לפשוטות יותר: "לוחצים על הלחצן הזה" או "מקישים על המקש הזה כדי לבצע את הפעולה X" במקום "מבצעים את הפעולה המתאימה בכלי העריכה כדי לבצע את הפעולה X".

אתם יכולים להשתמש בכל עורך שתרצו: Android Studio, סביבות פיתוח משולבות (IDE) אחרות של IntelliJ, Emacs,‏ Vim או Notepad++. כולם פועלים עם Flutter.

צילום מסך של VS Code עם קוד Flutter

בחירת יעד פיתוח

אפשר ליצור אפליקציות ל-Flutter לפלטפורמות שונות. האפליקציה יכולה לפעול בכל אחת ממערכות ההפעלה הבאות:

  • iOS
  • Android
  • Windows
  • macOS
  • Linux
  • אינטרנט

מקובל לבחור מערכת הפעלה אחת כיעד הפיתוח. זוהי מערכת ההפעלה שבה האפליקציה פועלת במהלך הפיתוח.

איור של מחשב נייד וטלפון שמחובר למחשב באמצעות כבל. המחשב הנייד מסומן בתווית

לדוגמה: נניח שאתם משתמשים במחשב נייד עם Windows כדי לפתח את אפליקציית Flutter. לאחר מכן, בוחרים ב-Android בתור יעד הפיתוח. כדי להציג תצוגה מקדימה של האפליקציה, מחברים מכשיר Android למחשב הנייד עם Windows באמצעות כבל USB, והאפליקציה שנמצאת בפיתוח פועלת במכשיר Android המחובר או במהדמ של Android. אפשר היה לבחור ב-Windows כיעד הפיתוח, שבו האפליקציה שנמצאת בפיתוח תפעל כאפליקציית Windows לצד הכלי לעריכה.

יכול להיות שתתפתתו לבחור באינטרנט כיעד הפיתוח. לכך יש חיסרון במהלך הפיתוח: אתם מפסידים את היכולת של Flutter לטעינה מחדש בזמן אמת (Hot Reload) עם שמירת המצב. בשלב הזה אי אפשר לטעון מחדש אפליקציות אינטרנט ב-Flutter.

בוחרים אפשרות לפני שממשיכים. תמיד תוכלו להריץ את האפליקציה במערכות הפעלה אחרות מאוחר יותר. בחירת יעד פיתוח תעזור לכם לבצע את השלב הבא בצורה חלקה יותר.

התקנת Flutter

ההוראות העדכניות ביותר להתקנת Flutter SDK מפורטות בכתובת docs.flutter.dev.

ההוראות באתר של Flutter כוללות התקנה של ה-SDK, של הכלים שקשורים ליעד הפיתוח ושל הפלאגינים של העריכה. כדי להשתמש ב-codelab הזה, צריך להתקין את התוכנות הבאות:

  1. Flutter SDK
  2. Visual Studio Code עם הפלאגין של Flutter
  3. תוכנת מהדר ליעד הפיתוח שבחרתם. (צריך את Visual Studio כדי לטרגט ל-Windows או את Xcode כדי לטרגט ל-macOS או ל-iOS)

בקטע הבא תלמדו ליצור את פרויקט Flutter הראשון שלכם.

אם אתם צריכים לפתור בעיות, יכול להיות שחלק מהשאלות והתשובות האלה (מ-StackOverflow) יעזרו לכם.

שאלות נפוצות

3.‏ יצירת פרויקט

יצירת הפרויקט הראשון ב-Flutter

לשם כך, פותחים את VS Code ויוצרים את תבנית האפליקציה של Flutter בספרייה שבוחרים.

  1. מריצים את Visual Studio Code.
  2. פותחים את לוח הפקודות (F1 או Ctrl+Shift+P או Shift+Cmd+P) ומקלידים 'flutter new'. כשהוא מופיע, בוחרים בפקודה Flutter: New Project.

צילום מסך של VS Code עם

  1. בוחרים באפשרות Empty Application. בוחרים ספרייה שבה ייווצר הפרויקט. זו יכולה להיות כל ספרייה שלא דורשת הרשאות מוגברות או שיש בה רווח בנתיב. דוגמאות לכך הן ספריית הבית או C:\src\.

צילום מסך של VS Code עם אפליקציה ריקה שמוצגת כמסומנת כחלק מתהליך היצירה של אפליקציה חדשה

  1. נותנים לפרויקט שם – brick_breaker. בהמשך הקודלהב הזה נניח שהשם של האפליקציה הוא brick_breaker.

צילום מסך של VS Code עם

עכשיו Flutter יוצר את תיקיית הפרויקט ו-VS Code פותח אותה. עכשיו נמחק את התוכן של שני קבצים ונחליף אותו בתוכנית בסיסית של האפליקציה.

העתקה והדבקה של האפליקציה הראשונית

הפעולה הזו מוסיפה לאפליקציה את קוד הדוגמה שסופק בקודלאב הזה.

  1. בחלונית הימנית של VS Code, לוחצים על Explorer ופותחים את הקובץ pubspec.yaml.

צילום מסך חלקי של VS Code עם חצים שמדגישים את המיקום של הקובץ pubspec.yaml

  1. מחליפים את התוכן של הקובץ הזה בקוד הבא:

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

קובץ pubspec.yaml מציין מידע בסיסי על האפליקציה, כמו הגרסה הנוכחית שלה, יחסי התלות שלה והנכסים שהיא תישלח איתם.

  1. פותחים את הקובץ main.dart בספרייה lib/.

צילום מסך חלקי של VS Code עם חץ שמראה את המיקום של הקובץ main.dart

  1. מחליפים את התוכן של הקובץ הזה בקוד הבא:

lib/main.dart

import 'package:flame/game.dart';
import 'package:flutter/material.dart';

void main() {
 
final game = FlameGame();
 
runApp(GameWidget(game: game));
}
  1. מריצים את הקוד הזה כדי לוודא שהכול פועל. אמור להופיע חלון חדש עם רקע שחור ריק בלבד. משחק הווידאו הכי גרוע בעולם מעובד עכשיו ב-60fps!

צילום מסך שבו מוצג חלון של אפליקציית brick_breaker שהוא שחור לגמרי.

4.‏ יצירת המשחק

סקירה כללית של המשחק

במשחקים שמתנהלים בשני ממדים (2D) צריך להגדיר אזור משחק. תיצרו אזור בגודל ספציפי, ואז תשתמשו בגודל הזה כדי לקבוע את הגודל של היבטים אחרים במשחק.

יש כמה דרכים למקם את הקואורדינטות באזור המשחק. לפי הסכמה אחת, אפשר למדוד את הכיוון ממרכז המסך, כאשר המקור (0,0)נמצא במרכז המסך. ערכים חיוביים מעבירים פריטים ימינה לאורך ציר x ומעלה לאורך ציר y. התקן הזה רלוונטי לרוב המשחקים הקיימים כיום, במיוחד למשחקים שכוללים שלושה מימדים.

כשהמשחק המקורי Breakout נוצר, ההסכמה הייתה להגדיר את המקור בפינה הימנית העליונה. כיוון ה-x החיובי נשאר ללא שינוי, אבל ה-y התהפך. הכיוון החיובי של x היה ימינה ו-y היה למטה. כדי לשמור על אותנטיות התקופה, המקור במשחק הזה מוגדר בפינה הימנית העליונה.

יוצרים קובץ בשם config.dart בספרייה חדשה בשם lib/src. בשלבים הבאים יתווספו לקובץ הזה עוד קבועים.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

המשחק הזה יהיה ברוחב 820 פיקסלים ובגובה 1,600 פיקסלים. אזור המשחק משתנה בהתאם לחלון שבו הוא מוצג, אבל כל הרכיבים שנוספו למסך מותאמים לגובה ולרוחב הזה.

יצירת PlayArea

במשחק Breakout, הכדור ניתז מהקירות של אזור המשחק. כדי לטפל בהתנגשויות, קודם צריך רכיב PlayArea.

  1. יוצרים קובץ בשם play_area.dart בספרייה חדשה בשם lib/src/components.
  2. מוסיפים את הטקסט הבא לקובץ הזה.

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);
 
}
}

ב-Flutter יש Widget, וב-Flame יש Component. אפליקציות Flutter מורכבות מעצים של ווידג'טים, ומשחקים ב-Flame מורכבים מעצים של רכיבים.

זהו הבדל מעניין בין Flutter לבין Flame. עץ הווידג'טים של Flutter הוא תיאור זמני שנועד לעדכן את השכבה RenderObject הקבועה והניתנת לשינוי. הרכיבים של Flame הם עמידים וניתנים לשינוי, והמפתחים אמורים להשתמש בהם כחלק ממערכת סימולציה.

הרכיבים של Flame מותאמים במיוחד להצגת המכניקה של המשחק. סדנת הקוד הזו תתחיל בלולאת המשחק, שתוצג בשלב הבא.

  1. כדי למנוע עומס, מוסיפים קובץ שמכיל את כל הרכיבים בפרויקט הזה. יוצרים קובץ components.dart ב-lib/src/components ומוסיפים את התוכן הבא.

lib/src/components/components.dart

export 'play_area.dart';

ההנחיה export ממלאת את התפקיד ההפוך של import. הוא מצהיר על הפונקציונליות שהקובץ הזה חושף כשמייבאים אותו לקובץ אחר. ככל שתוסיפו רכיבים חדשים בשלבים הבאים, יתווספו עוד רשומות לקובץ הזה.

יצירת משחק ב-Flame

כדי להפסיק את ההצגה של הקווים האדומים מהשלב הקודם, יוצרים צאצא חדש של FlameGame של Flame.

  1. יוצרים קובץ בשם brick_breaker.dart ב-lib/src ומוסיפים את הקוד הבא.

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());
 
}
}

הקובץ הזה מרכז את הפעולות של המשחק. במהלך היצירה של מופע המשחק, הקוד הזה מגדיר את המשחק להשתמש ברינדור ברזולוציה קבועה. המשחק יותאם לגודל המסך שמכיל אותו, ויתווסף לו letterboxing לפי הצורך.

צריך לחשוף את הרוחב והגובה של המשחק כדי שרכיבי הצאצאים, כמו PlayArea, יוכלו להגדיר את עצמם בגודל המתאים.

בשיטה onLoad שהוחלפה, הקוד מבצע שתי פעולות.

  1. מגדיר את הפינה השמאלית העליונה כציון העוגן של העינית. כברירת מחדל, ה-viewfinder משתמש במרכז האזור כעוגן ל-(0,0).
  2. הוספת PlayArea ל-world. העולם מייצג את עולם המשחק. הוא משקף את כל הצאצאים שלו באמצעות טרנספורמציית התצוגה של CameraComponent.

הצגת המשחק במסך

כדי לראות את כל השינויים שביצעתם בשלב הזה, מעדכנים את הקובץ lib/main.dart עם השינויים הבאים.

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));
}

אחרי ביצוע השינויים האלה, מפעילים מחדש את המשחק. המשחק אמור להיראות כמו בתרשים הבא.

צילום מסך שבו מוצג חלון של אפליקציית brick_breaker עם מלבן בצבע חול באמצע חלון האפליקציה

בשלב הבא תוסיפו כדור לעולם ותגרמו לו לזוז.

5.‏ הצגת הכדור

יצירת הרכיב של הכדור

כדי להציג כדור נע במסך, צריך ליצור רכיב נוסף ולהוסיף אותו לעולם המשחק.

  1. עורכים את התוכן של הקובץ lib/src/config.dart באופן הבא.

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;                            // Add this constant

תבנית העיצוב של הגדרת קבועים עם שם כערכים נגזרים תופיע פעמים רבות בקודלאב הזה. כך תוכלו לשנות את הערכים ברמה העליונה gameWidth ו-gameHeight כדי לראות איך המראה והתחושה של המשחק משתנים כתוצאה מכך.

  1. יוצרים את הרכיב Ball בקובץ בשם ball.dart ב-lib/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;
 
}
}

קודם הגדרתם את PlayArea באמצעות RectangleComponent, ולכן הגיוני שיש עוד צורות. CircleComponent, כמו RectangleComponent, נגזר מ-PositionedComponent, כך שאפשר למקם את הכדור במסך. חשוב יותר, אפשר לעדכן את המיקום שלו.

הרכיב הזה מציג את המושג velocity, או שינוי המיקום לאורך זמן. מהירות היא אובייקט מסוג Vector2 כי מהירות היא גם מהירות וגם כיוון. כדי לעדכן את המיקום, משנים את השיטה update שמנוע המשחק קורא לה בכל פריים. הערך של dt הוא משך הזמן בין המסגרת הקודמת למסגרת הזו. כך תוכלו להתאים את עצמכם לגורמים כמו שיעורי פריימים שונים (60Hz או 120Hz) או פריימים ארוכים כתוצאה מעיבוד מוגזם.

חשוב לשים לב לעדכון position += velocity * dt. כך מטמיעים עדכון של סימולציה דיסקרטית של תנועה לאורך זמן.

  1. כדי לכלול את הרכיב Ball ברשימת הרכיבים, עורכים את הקובץ lib/src/components/components.dart באופן הבא.

lib/src/components/components.dart

export 'ball.dart';                           // Add this export
export 'play_area.dart';

הוספת הכדור לעולם

יש לכם כדור. מניחים אותו בעולם ומגדירים אותו לנוע ברחבי אזור המשחק.

עורכים את הקובץ lib/src/brick_breaker.dart באופן הבא.

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.
 
}
}

השינוי הזה מוסיף את הרכיב Ball ל-world. כדי להגדיר את position של הכדור במרכז אזור התצוגה, הקוד קודם מקטין את גודל המשחק לשני שלישים, כי ל-Vector2 יש עומסי יתר של אופרטורים (* ו-/) כדי לשנות את קנה המידה של Vector2 לפי ערך סקלר.

הגדרת velocity של הכדור מורכבת יותר. המטרה היא להזיז את הכדור למטה במסך בכיוון אקראי ובמהירות סבירה. הקריאה ל-method‏ normalized יוצרת אובייקט Vector2 שמוגדר לאותה כיוון כמו Vector2 המקורי, אבל מצומצם למרחק של 1. כך המהירות של הכדור נשארת עקבית, לא משנה לאיזה כיוון הוא הולך. לאחר מכן, מהירות הכדור מוגדלת כך שתהיה שווה ל-1/4 מגובה המשחק.

כדי להגיע לערכים הנכונים, צריך לבצע כמה חזרות, שנקראות בתעשייה 'בדיקת גיימינג'.

השורה האחרונה מפעילה את תצוגת ניפוי הבאגים, שמוסיפה מידע נוסף לתצוגה כדי לעזור בניפוי הבאגים.

כשמפעילים את המשחק, הוא אמור להיראות כמו התצוגה הבאה.

צילום מסך שבו מוצג חלון של אפליקציית brick_breaker עם עיגול כחול מעל מלבן בצבע חול. המעגל הכחול מסומן במספרים שמציינים את הגודל והמיקום שלו במסך

גם לרכיב PlayArea וגם לרכיב Ball יש מידע על ניפוי באגים, אבל שכבות המט של הרקע חתוכות כך שהמספרים של PlayArea לא מוצגים. הסיבה לכך שמוצגים נתוני ניפוי באגים בכל מקום היא שהפעלתם את debugMode לכל עץ הרכיבים. אם זה שימושי יותר, אפשר גם להפעיל את ניפוי הבאגים רק לרכיבים נבחרים.

אם תפעילו מחדש את המשחק כמה פעמים, יכול להיות שתבחינו שהכדור לא מתנהג עם הקירות כמצופה. כדי להשיג את האפקט הזה, צריך להוסיף זיהוי התנגשויות. בשלב הבא נסביר איך עושים את זה.

6.‏ קפיצה ממקום למקום

הוספת זיהוי התנגשויות

זיהוי התנגשויות מוסיף התנהגות שבה המשחק מזהה מתי שני אובייקטים נכנסים במגע זה עם זה.

כדי להוסיף למשחק זיהוי התנגשויות, מוסיפים את ה-mixin HasCollisionDetection למשחק BrickBreaker כפי שמוצג בקוד הבא.

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;
 
}
}

כך אפשר לעקוב אחרי אזורי ההיט של הרכיבים ולהפעיל קריאות חזרה (callbacks) של התנגשויות בכל טייק של המשחק.

כדי להתחיל לאכלס את ה-hitboxes של המשחק, משנים את הרכיב PlayArea כפי שמתואר בהמשך.

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);
 
}
}

הוספת רכיב RectangleHitbox כצאצא של RectangleComponent תיצור תיבת פגיעה לזיהוי התנגשויות שתתאים לגודל של רכיב ההורה. יש ב-RectangleHitbox קונסטרוקטור של מפעל שנקרא relative, לפעמים שבהן רוצים hitbox קטן או גדול יותר מהרכיב ההורה.

להקפיץ את הכדור

עד עכשיו, הוספת זיהוי התנגשויות לא השפיעה על משחקיות. הוא משתנה אחרי שמבצעים שינוי ברכיב Ball. התנהגות הכדור צריכה להשתנות כשהוא מתנגש ב-PlayArea.

משנים את הרכיב Ball באופן הבא.

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.
}

בדוגמה הזו יש שינוי משמעותי עם הוספת קריאה חוזרת (callback) של onCollisionStart. מערכת זיהוי ההתנגשויות שנוספה ל-BrickBreaker בדוגמה הקודמת קוראת ל-callback הזה.

קודם הקוד בודק אם Ball התנגש ב-PlayArea. נראה שזה מיותר כרגע, כי אין רכיבים אחרים בעולם המשחק. המצב ישתנה בשלב הבא, כשמוסיפים עטלף לעולם. לאחר מכן, המערכת מוסיפה גם תנאי else לטיפול במקרים שבהם הכדור מתנגש בדברים שאינם המחבט. תזכורת חביבה להטמיע את שאר הלוגיקה, אם תרצו.

כשהכדור פוגש בקיר התחתון, הוא פשוט נעלם משטח המשחק, אבל עדיין נראה בבירור. לטפל בבעיה הזו בשלב עתידי, באמצעות היכולות של Flame Effects.

עכשיו, כשהכדור פוגש בקירות המשחק, כדאי לתת לשחקן מחבט כדי להכות בכדור…

7.‏ להכות בכדור

יצירת ה-bat

כדי להוסיף מקל בייסבול כדי שהכדור יישאר במשחק,

  1. מוסיפים כמה קבועים לקובץ lib/src/config.dart באופן הבא.

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.

הקבועים batHeight ו-batWidth מובנים מאליהם. לעומת זאת, צריך להסביר קצת על הקבועה batStep. כדי ליצור אינטראקציה עם הכדור במשחק הזה, השחקן יכול לגרור את המחבט בעזרת העכבר או האצבע, בהתאם לפלטפורמה, או להשתמש במקלדת. הקבוע batStep קובע את המרחק שאליו יתקדם העטלף בכל לחיצה על מקש החץ שמאלה או ימינה.

  1. מגדירים את סוג הרכיב Bat באופן הבא.

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),
     
),
   
);
 
}
}

הרכיב הזה כולל כמה יכולות חדשות.

ראשית, רכיב ה-Bat הוא PositionComponent, ולא RectangleComponent או CircleComponent. המשמעות היא שהקוד הזה צריך להציג את Bat במסך. כדי לעשות זאת, הוא מבטל את ההפעלה החוזרת (callback) של render.

אם תבחנו לעומק את הקריאה canvas.drawRRect (ציור מלבן מעוגל), יכול להיות שתתהו "איפה המלבן?" הפונקציה Offset.zero & size.toSize() משתמשת בעומס יתר של operator & בכיתה Offset של dart:ui, שיוצרת Rect. יכול להיות שהקיצור הזה יבלבל אתכם בהתחלה, אבל תראו אותו לעיתים קרובות בקוד ברמה נמוכה יותר של Flutter ו-Flame.

שנית, אפשר לגרור את הרכיב Bat באמצעות אצבע או עכבר, בהתאם לפלטפורמה. כדי להטמיע את הפונקציונליות הזו, מוסיפים את ה-mixin DragCallbacks ומחליפים את האירוע onDragUpdate.

לבסוף, הרכיב Bat צריך להגיב לשליטה במקלדת. הפונקציה moveBy מאפשרת לקוד אחר להורות לעטלף הזה לזוז שמאלה או ימינה במספר מסוים של פיקסלים וירטואליים. הפונקציה הזו מציגה יכולת חדשה של מנוע המשחק Flame: Effects. הוספת האובייקט MoveToEffect כצאצא של הרכיב הזה מאפשרת לשחקן לראות את העטלף נע למוצב חדש. יש ב-Flame אוסף של Effects שאפשר להשתמש בהם כדי ליצור מגוון אפקטים.

הארגומנטים של ה-constructor של Effect כוללים הפניה ל-getter של game. לכן צריך לכלול את ה-mixin HasGameReference בכיתה הזו. ה-mixin הזה מוסיף לרכיב הזה רכיב גישה (accessor) מסוג game ללא סיכון בטיפוסים, כדי לגשת למכונה BrickBreaker בחלק העליון של עץ הרכיבים.

  1. כדי שה-Bat יהיה זמין ל-BrickBreaker, מעדכנים את הקובץ lib/src/components/components.dart באופן הבא.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';                                              // Add this export
export 'play_area.dart';

הוספת העטלף לעולם

כדי להוסיף את הרכיב Bat לעולם המשחק, מעדכנים את BrickBreaker באופן הבא.

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.
}

הוספת ה-mixin KeyboardEvents והשיטה onKeyEvent שהוחלפה מאפשרות לטפל בקלט מהמקלדת. נזכרים בקוד שהוספתם קודם כדי להזיז את המחבט בגודל הצעד המתאים.

החלק הנותר של הקוד שנוסף מוסיף את העטלף לעולם המשחק במיקום המתאים ובפרופורציות הנכונות. העובדה שכל ההגדרות האלה גלויות בקובץ הזה מאפשרת לכם לשנות בקלות את הגודל היחסי של המחבט והכדור כדי לקבל את התחושה המתאימה למשחק.

אם תשחקו במשחק בשלב הזה, תוכלו להזיז את המחבט כדי ליירט את הכדור, אבל לא תקבלו תגובה גלויה, מלבד הרישום ביומן לניפוי באגים שהשארתם בקוד לזיהוי התנגשות של Ball.

הגיע הזמן לתקן את זה. עורכים את הרכיב Ball באופן הבא.

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');
   
}
 
}
}

שינויי הקוד האלה מתקנים שתי בעיות נפרדות.

קודם כול, תוקנה הבעיה שבה הכדור נעלם ברגע שהוא נוגע בחלק התחתון של המסך. כדי לפתור את הבעיה, מחליפים את הקריאה ל-removeFromParent בקריאה ל-RemoveEffect. הפונקציה RemoveEffect מסירה את הכדור מעולם המשחק אחרי שהוא יוצא מאזור המשחק שגלוי לצופה.

שנית, השינויים האלה מתקנים את הטיפול בהתנגשויות בין המחבט לכדור. קוד הטיפול הזה פועל לטובת השחקן. כל עוד השחקן נוגע בכדור עם המחבט, הכדור חוזר לחלק העליון של המסך. אם אתם מרגישים שהמשחק קל מדי ואתם רוצים משהו ריאליסטי יותר, תוכלו לשנות את הטיפול הזה כך שיתאים יותר לאופי המשחק שאתם רוצים.

חשוב לציין את המורכבות של עדכון velocity. לא רק שהרכיב y של המהירות הפוך, כמו במקרה של ההתנגשויות בקיר. הוא גם מעדכן את הרכיב x באופן שמשתנה בהתאם למיקום היחסי של המחבט והכדור בזמן המגע. כך לשחקן יש יותר שליטה על מה שקורה לכדור, אבל האופן שבו הכדור יתנהג לא ידווח לשחקן בשום צורה מלבד במהלך המשחק.

עכשיו שיש לכם מחבט להכות בו בכדור, כדאי שתהיה לכם גם לבנה שאפשר יהיה לשבור עם הכדור.

8.‏ הורדת החומה

יצירת הלבנים

כדי להוסיף לבנים למשחק:

  1. מוסיפים כמה קבועים לקובץ lib/src/config.dart באופן הבא.

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.
  1. מוסיפים את הרכיב Brick באופן הבא.

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>());
   
}
 
}
}

רוב הקוד הזה כבר מוכר לכם. הקוד הזה משתמש ב-RectangleComponent, עם זיהוי התנגשויות והפניה ללא סיכון בטיפוסים למשחק BrickBreaker בחלק העליון של עץ הרכיבים.

הקונספט החדש החשוב ביותר שהקוד הזה מציג הוא האופן שבו השחקן משיג את תנאי הניצחון. בדיקת תנאי הזכייה שולחת שאילתה לעולם כדי לבדוק אם יש בו לבנים, ומוודאת שנותרה רק לבנה אחת. יכול להיות שזה קצת מבלבל, כי בשורה הקודמת הלבנה הזו הוסרה מהורה שלה.

חשוב להבין שהסרת רכיבים היא פקודה שממתינה בתור. הוא מסיר את הלבנה אחרי שהקוד הזה פועל, אבל לפני הטיקים הבאים של עולם המשחק.

כדי לאפשר לרכיב Brick להיות נגיש ל-BrickBreaker, עורכים את lib/src/components/components.dart באופן הבא.

lib/src/components/components.dart

export 'ball.dart';
export 'bat.dart';
export 'brick.dart';                                            // Add this export
export 'play_area.dart';

הוספת לבנים לעולם

מעדכנים את הרכיב Ball באופן הבא.

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.
   
}
 
}
}

כאן מוצג האספקט החדש היחיד, משתנה קושי שמגביר את מהירות הכדור אחרי כל התנגשות עם לבנים. צריך לבדוק את הפרמטר הזה במהלך משחק כדי למצוא את עקומת הקושי המתאימה למשחק.

עורכים את המשחק BrickBreaker באופן הבא.

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;
 
}
}

אם תפעילו את המשחק במצב הנוכחי, יוצגו בו כל המכניקות העיקריות של המשחק. אפשר להשבית את ניפוי הבאגים ולהגיד שסיימתם, אבל משהו חסר.

צילום מסך שבו מוצג brick_breaker עם כדור, מחבט ורוב הלבנים באזור המשחק. לכל אחד מהרכיבים יש תוויות לניפוי באגים

מה דעתכם על מסך פתיחה, מסך 'הפסד מוחץ' ואולי גם תוצאה? אפשר להוסיף את התכונות האלה למשחק באמצעות Flutter, וזה מה שנעשה בשלב הבא.

9.‏ לנצח במשחק

הוספת מצבי משחק

בשלב הזה, מטמיעים את המשחק Flame בתוך מעטפת של Flutter, ואז מוסיפים שכבות-על של Flutter למסכים של קבלת הפנים, הסיום והזכייה.

קודם כול, משנים את קובצי המשחק והרכיבים כדי להטמיע מצב משחק שמשקף אם להציג שכבת-על, ואם כן, איזו שכבת-על.

  1. משנים את המשחק BrickBreaker באופן הבא.

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
}

הקוד הזה משנה חלק גדול מהמשחק BrickBreaker. הוספת המניין playState דורשת הרבה עבודה. הנתונים האלה מתעדים את השלבים שבהם השחקן נכנס למשחק, משחק בו ומפסיד או מנצח בו. בחלק העליון של הקובץ מגדירים את המניין, ואז יוצרים מופע שלו כמצב מוסתר עם פונקציות getter ו-setter תואמות. פונקציות ה-getter וה-setter האלה מאפשרות לשנות שכבות-על כשחלקים שונים במשחק מפעילים מעברים במצב המשחק.

בשלב הבא, מחלקים את הקוד ב-onLoad ל-onLoad ולשיטה חדשה של startGame. לפני השינוי הזה, אפשר היה להתחיל משחק חדש רק על ידי הפעלה מחדש של המשחק. בעזרת התוספות החדשות האלה, השחקן יכול עכשיו להתחיל משחק חדש בלי לנקוט אמצעים דרסטיים כאלה.

כדי לאפשר לשחקן להתחיל משחק חדש, הגדרתם שני מנהלים חדשים למשחק. הוספתם טיפול בהקשה והרחבתם את הטיפול במקלדת כדי לאפשר למשתמש להתחיל משחק חדש במספר מודלים. אחרי שיוצרים מודל של מצב המשחק, כדאי לעדכן את הרכיבים כדי להפעיל את מעברי מצב המשחק כשהשחקן מנצח או מפסיד.

  1. משנים את הרכיב Ball באופן הבא.

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);
   
}
 
}
}

השינוי הקטן הזה מוסיף קריאה חוזרת (callback) מסוג onComplete ל-RemoveEffect, שמפעילה את מצב ההפעלה gameOver. המיקום הזה אמור להרגיש נכון אם השחקן מאפשר לכדור לצאת מתחתית המסך.

  1. עורכים את הרכיב Brick באופן הבא.

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>());
   
}
 
}
}

לעומת זאת, אם השחקן מצליח לשבור את כל הלבנים, הוא מקבל מסך עם הכיתוב 'הנצחת במשחק'. כל הכבוד לשחקן, כל הכבוד!

הוספת המעטפת של Flutter

כדי לספק מקום להטמעת המשחק ולהוסיף שכבות-על של מצב המשחק, מוסיפים את המעטפת של Flutter.

  1. יוצרים ספרייה widgets ב-lib/src.
  2. מוסיפים קובץ game_app.dart ומוסיפים את התוכן הבא לקובץ הזה.

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,
                         
),
                       
),
                     
},
                   
),
                 
),
               
),
             
),
           
),
         
),
       
),
     
),
   
);
 
}
}

רוב התוכן בקובץ הזה עוקב אחרי build סטנדרטי של עץ ווידג'טים ב-Flutter. החלקים הספציפיים ל-Flame כוללים שימוש ב-GameWidget.controlled כדי ליצור ולנהל את מכונה של המשחק BrickBreaker ואת הארגומנט החדש overlayBuilderMap ל-GameWidget.

המפתחות של overlayBuilderMap צריכים להיות תואמים לשכבות-העל שהוספו או הוסרו על ידי הגדרת playState ב-BrickBreaker. ניסיון להגדיר שכבת-על שלא נמצאת במפה הזו יוביל לפרצופים לא מרוצים מכל עבר.

  1. כדי להציג את הפונקציונליות החדשה הזו במסך, צריך להחליף את הקובץ lib/main.dart בתוכן הבא.

lib/main.dart

import 'package:flutter/material.dart';

import 'src/widgets/game_app.dart';

void main() {
 
runApp(const GameApp());
}

אם מריצים את הקוד הזה ב-iOS, ב-Linux, ב-Windows או באינטרנט, הפלט המיועד יוצג במשחק. אם אתם מטרגטים את macOS או Android, תצטרכו לבצע שינוי אחרון כדי לאפשר את הצגת google_fonts.

הפעלת הגישה לגופנים

הוספת הרשאת אינטרנט ל-Android

ב-Android, צריך להוסיף הרשאת אינטרנט. עורכים את AndroidManifest.xml באופן הבא.

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>

עריכת קובצי הרשאות ל-macOS

ב-macOS, צריך לערוך שני קבצים.

  1. עורכים את הקובץ DebugProfile.entitlements כך שיהיה זהה לקוד הבא.

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>
  1. עורכים את הקובץ Release.entitlements כך שיהיה זהה לקוד הבא

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>

כשמריצים את הקוד הזה כפי שהוא, אמור להופיע מסך קבלת פנים ומסך של סיום המשחק או של ניצחון בכל הפלטפורמות. המסכים האלה עשויים להיות קצת פשוטים מדי, ורצוי שיהיה ציון. נחשו מה נעשה בשלב הבא!

10.‏ שמירת ניקוד

הוספת ניקוד למשחק

בשלב הזה, חושפים את ניקוד המשחק להקשר של Flutter שמקיף אותו. בשלב הזה חושפים את המצב של משחק Flame לניהול המצב של Flutter. כך קוד המשחק יכול לעדכן את הציון בכל פעם שהשחקן שובר לבנה.

  1. משנים את המשחק BrickBreaker באופן הבא.

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);
}

הוספת score למשחק מקשרת את המצב של המשחק לניהול המצב ב-Flutter.

  1. משנים את הכיתה Brick כדי להוסיף נקודה לניקוד כשהשחקן שובר לבנים.

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>());
   
}
 
}
}

איך יוצרים משחק שנראה טוב

עכשיו, אחרי שסיימתם לעקוב אחרי התוצאות ב-Flutter, הגיע הזמן להרכיב את הווידג'טים כדי שהם ייראו טוב.

  1. יוצרים את score_card.dart ב-lib/src/widgets ומוסיפים את הפרטים הבאים.

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!,
         
),
       
);
     
},
   
);
 
}
}
  1. יוצרים את הקובץ overlay_screen.dart ב-lib/src/widgets ומוסיפים את הקוד הבא.

כך אפשר לשפר את שכבות-העל באמצעות חבילת flutter_animate כדי להוסיף תנועה וסגנון למסכי שכבת-העל.

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),
       
],
     
),
   
);
 
}
}

כדי לקבל מידע מעמיק יותר על היכולות של flutter_animate, מומלץ לעיין בקודלאב יצירת ממשקי משתמש מדור הבא ב-Flutter.

הקוד הזה השתנה מאוד ברכיב GameApp. קודם כול, כדי לאפשר ל-ScoreCard לגשת ל-score , צריך להמיר אותו מ-StatelessWidget ל-StatefulWidget. כדי להוסיף את כרטיס המידע, צריך להוסיף Column כדי להציב את התוצאה מעל המשחק.

שנית, כדי לשפר את חוויית הפתיחה, הסיום והזכייה, הוספתם את הווידג'ט החדש 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.
             
),
           
),
         
),
       
),
     
),
   
);
 
}
}

עכשיו אמורה להיות לכם אפשרות להריץ את המשחק בכל אחת משש פלטפורמות היעד של Flutter. המשחק אמור להיראות כך.

צילום מסך של brick_breaker שבו מוצג המסך שלפני המשחק שמזמין את המשתמש להקיש על המסך כדי לשחק במשחק

צילום מסך של brick_breaker שבו מוצג מסך הסיום של המשחק מעל מחבט וחלק מהלבנים

11.‏ מזל טוב

ברכות, הצלחתם ליצור משחק באמצעות Flutter ו-Flame!

פיתחתם משחק באמצעות מנוע המשחקים Flame 2D והטמעתם אותו ב-wrapper של Flutter. השתמשתם באפקטים של Flame כדי להוסיף אנימציה לרכיבים ולהסיר אותם. השתמשתם ב-Google Fonts ובחבילות Flutter Animate כדי שהמשחק כולו ייראה מעוצב היטב.

מה השלב הבא?

כדאי לעיין בחלק מהקורסים האלה ב-Codelab…

קריאה נוספת