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

1. מבוא

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

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

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

מה תלמדו

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

מה תפַתחו

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

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

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

הרשאת עריכה

כדי לפשט את ה-codelab הזה, אנחנו מניחים שסביבת הפיתוח שלכם היא Visual Studio Code‏ (VS Code). השימוש ב-VS Code הוא בחינם, והוא פועל בכל הפלטפורמות העיקריות. אנחנו משתמשים ב-VS Code ב-codelab הזה כי ההוראות מוגדרות כברירת מחדל לקיצורי דרך ספציפיים ל-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

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

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

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

בקטע הבא, תיצרו את פרויקט 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. בהמשך ה-codelab הזה נניח ששם האפליקציה הוא brick_breaker.

‫VS Code עם

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

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

פעולה זו תוסיף לאפליקציה שלכם את קוד הדוגמה שמופיע ב-codelab הזה.

  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 מציין מידע בסיסי על האפליקציה, כמו הגרסה הנוכחית שלה, התלות שלה ונכסי ה-assets שייכללו בה.

  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 פיקסלים. אזור המשחק מותאם לגודל החלון שבו הוא מוצג, אבל כל הרכיבים שנוספו למסך מותאמים לגובה ולרוחב האלה.

יצירת אזור משחק

במשחק 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 יש Widgets, וב-Flame יש Components. באפליקציות Flutter יוצרים עצי ווידג'טים, ובמשחקי Flame יוצרים עצי רכיבים.

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

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

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

lib/src/components/components.dart

export 'play_area.dart';

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

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

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

  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

תבנית העיצוב של הגדרת קבועים בעלי שם כערכים נגזרים תחזור על עצמה הרבה פעמים ב-codelab הזה. כך תוכלו לשנות את הרמה העליונה 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 הוא משך הזמן בין הפריים הקודם לפריים הנוכחי. כך תוכלו להתאים את עצמכם לגורמים כמו קצבי פריימים שונים (60 הרץ או 120 הרץ) או פריימים ארוכים בגלל חישובים מוגזמים.

חשוב לשים לב לעדכון 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. כך מהירות הכדור נשארת קבועה לא משנה לאיזה כיוון הכדור נע. המהירות של הכדור מוגדלת כך שתהיה רבע מגובה המשחק.

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

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

כשמפעילים את המשחק, הוא אמור להיראות כך:

צילום מסך שמציג חלון של אפליקציית 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) של התנגשות בכל טיק במשחק.

כדי להתחיל לאכלס את תיבות הפגיעה של המשחק, משנים את רכיב 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 בנאי (constructor) שנקרא relative, שמאפשר ליצור תיבת פגיעה קטנה או גדולה יותר מהרכיב האב.

הקפצת הכדור

עד עכשיו, הוספת זיהוי התנגשויות לא השפיעה על המשחקיות. הוא משתנה רק כשמשנים את הרכיב 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.
}

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

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

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

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

7. Get bat on ball

יצירת קובץ ה-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 (draw rounded rectangle), יכול להיות שתשאלו את עצמכם: "איפה המלבן?". ‫Offset.zero & size.toSize() משתמש בעומס יתר של operator & בכיתה dart:ui Offset שיוצרת Rect. יכול להיות שהקיצור הזה יבלבל אתכם בהתחלה, אבל הוא מופיע לעיתים קרובות בקוד Flutter ו-Flame ברמה נמוכה יותר.

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

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

ארגומנטי ה-constructor של האפקט כוללים הפניה ל-getter‏ game. לכן צריך לכלול את ה-mixin‏ HasGameReference בכיתה הזו. ה-mixin הזה מוסיף לרכיב הזה פונקציית גישה 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 תואמות. הפונקציות האלה מאפשרות לשנות את שכבות העל כשחלקים שונים במשחק מפעילים מעברים בין מצבי הפעלה.

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

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

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

השינוי הקטן הזה מוסיף 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 shell.

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

רוב התוכן בקובץ הזה מבוסס על יצירה של עץ ווידג'טים רגיל ב-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, כדאי לעיין ב-codelab Building next generation UIs in 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 והטמעת אותו במעטפת Flutter. השתמשתם באפקטים של Flame כדי להנפיש ולהסיר רכיבים. השתמשת בחבילות Google Fonts ו-Flutter Animate כדי שהמשחק כולו ייראה מעוצב היטב.

מה השלב הבא?

כדאי לעיין בכמה מה-codelabs האלה…

קריאה נוספת