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

1. מבוא

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

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

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

מה תלמדו

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

מה תפַתחו

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

  • האפליקציה פועלת בכל שש הפלטפורמות שנתמכות ב-Flutter: Android, iOS, Linux, macOS, Windows והאינטרנט
  • שמירה על קצב פריימים של לפחות 60FPS באמצעות לולאת המשחק של 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 לצד העורך.

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

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

להתקנת Flutter

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

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

  1. SDK של Flutter
  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. המשך התהליך הזה ב-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.3.0 <4.0.0'

dependencies:
  flame: ^1.16.0
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.0
  google_fonts: ^6.1.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.1

flutter:
  uses-material-design: true

הקובץ 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. יצירת המשחק

משדרגים את המשחק

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

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

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

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

lib/src/config.dart

const gameWidth = 820.0;
const gameHeight = 1600.0;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6. קפיצות

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

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

כדי להוסיף זיהוי התנגשויות למשחק, יש להוסיף את המיקס של 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;
  }
}

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

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

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

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

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

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

7. חובטים במחבט על הכדור

יצירת מחבט

כדי להוסיף מחבט כדי שהכדור ימשיך לשחק במשחק:

  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 במסך. כדי לעשות זאת, הוא מבטל את הקריאה החוזרת של render.

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

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

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

הארגומנטים של constructor של האפקט כוללים הפניה ל-Getter של game. לכן בחרת לכלול את המיקס של HasGameReference בכיתה הזו. השילוב הזה מוסיף לרכיב הזה גישת 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(Bat(                                              // Add from here...
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95)));          // To here

    debugMode = true;
  }

  @override                                                     // Add from here...
  KeyEventResult onKeyEvent(
      KeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Bat>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Bat>().first.moveBy(batStep);
    }
    return KeyEventResult.handled;
  }                                                             // To here
}

ההוספה של המיקסין של 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(                                       // Modify from here...
          delay: 0.35,
        ));
      }
    } else if (other is Bat) {
      velocity.y = -velocity.y;
      velocity.x = velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else {                                                    // To here.
      debugPrint('collision with $other');
    }
  }
}

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

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

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

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

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

9. מנצחים במשחק

הוספת מצבי Play

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

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

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

import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';

import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';

class Brick extends RectangleComponent
    with CollisionCallbacks, HasGameReference<BrickBreaker> {
  Brick({required super.position, required Color color})
      : super(
          size: Vector2(brickWidth, brickHeight),
          anchor: Anchor.center,
          paint: Paint()
            ..color = color
            ..style = PaintingStyle.fill,
          children: [RectangleHitbox()],
        );

  @override
  void onCollisionStart(
      Set<Vector2> intersectionPoints, PositionComponent other) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();

    if (game.world.children.query<Brick>().length == 1) {
      game.playState = PlayState.won;                          // Add this line
      game.world.removeAll(game.world.children.query<Ball>());
      game.world.removeAll(game.world.children.query<Bat>());
    }
  }
}

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

הוספת ה-Flutter wrapper

כדי לספק מקום שבו אפשר להטמיע את המשחק ולהוסיף שכבות-על של מצב המשחק, מוסיפים את מעטפת 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(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: BrickBreaker.new,
                      overlayBuilderMap: {
                        PlayState.welcome.name: (context, game) => Center(
                              child: Text(
                                'TAP TO PLAY',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.gameOver.name: (context, game) => Center(
                              child: Text(
                                'G A M E   O V E R',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                        PlayState.won.name: (context, game) => Center(
                              child: Text(
                                'Y O U   W O N ! ! !',
                                style:
                                    Theme.of(context).textTheme.headlineLarge,
                              ),
                            ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

רוב התוכן בקובץ הזה תואם לפיתוח סטנדרטי של עץ ווידג'ט Flutter. החלקים הספציפיים ל-Fflame כוללים שימוש ב-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 של 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(
        useMaterial3: true,
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: const Color(0xff184e77),
          displayColor: const Color(0xff184e77),
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: [
                Color(0xffa9d6e5),
                Color(0xfff2e8cf),
              ],
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: Column(                                  // Modify from here...
                  children: [
                    ScoreCard(score: game.score),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.welcome.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'TAP TO PLAY',
                                    subtitle: 'Use arrow keys or swipe',
                                  ),
                              PlayState.gameOver.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'G A M E   O V E R',
                                    subtitle: 'Tap to Play Again',
                                  ),
                              PlayState.won.name: (context, game) =>
                                  const OverlayScreen(
                                    title: 'Y O U   W O N ! ! !',
                                    subtitle: 'Tap to Play Again',
                                  ),
                            },
                          ),
                        ),
                      ),
                    ),
                  ],
                ),                                              // To here.
              ),
            ),
          ),
        ),
      ),
    );
  }
}

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

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

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

11. מזל טוב

כל הכבוד, הצלחת לבנות משחק עם Flutter ו-Fflame!

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

מה השלב הבא?

כדאי לנסות כמה מ-Codelabs האלה...

קריאה נוספת