מידע על Codelab זה
1. מבוא
Flame הוא מנוע משחקים דו-ממדי שמבוסס על Flutter. בקודלאב הזה תלמדו ליצור משחק בהשראת אחד ממשחקי הווידאו הקלאסיים של שנות ה-70, Breakout של סטיב ווזניאק. כדי לצייר את המחבט, הכדור והלבנים, תשתמשו ברכיבים של Flame. נשתמש באפקטים של Flame כדי ליצור אנימציה לתנועת העטלף, ונראה איך לשלב את Flame עם מערכת ניהול המצבים של Flutter.
בסיום, המשחק אמור להיראות כמו קובץ ה-GIF האנימטי הזה, אם כי מעט איטי יותר.
מה תלמדו
- הסבר על העקרונות הבסיסיים של Flame, החל מ-
GameWidget
. - איך משתמשים בלולאת משחק.
- איך פועלים
Component
של Flame. הם דומים ל-Widget
של Flutter. - איך מטפלים בהתנגשויות.
- איך משתמשים ב-
Effect
כדי ליצור אנימציה ל-Component
. - איך להוסיף שכבת-על של
Widget
ב-Flutter למשחק ב-Flame. - איך לשלב את Flame עם ניהול המצב של Flutter.
מה תפַתחו
בשיעור ה-Codelab הזה תלמדו ליצור משחק דו-מימדי באמצעות Flutter ו-Flame. בסיום, המשחק צריך לעמוד בדרישות הבאות:
- הפעולה מתבצעת בכל שש הפלטפורמות שתומכות ב-Flutter: Android, iOS, Linux, macOS, Windows והאינטרנט
- לשמור על קצב של 60fps לפחות באמצעות לולאת המשחק של Flame.
- אתם יכולים להשתמש ביכולות של Flutter, כמו החבילה
google_fonts
ו-flutter_animate
, כדי ליצור מחדש את האווירה של משחקי ארקייד משנות ה-80.
2. הגדרת סביבת Flutter
הרשאת עריכה
כדי לפשט את הקודלאב הזה, נניח ש-Visual Studio Code (VS Code) היא סביבת הפיתוח שלכם. VS Code הוא כלי חינמי שפועל בכל הפלטפורמות העיקריות. אנחנו משתמשים ב-VS Code בסדנת הקוד הזו כי ההוראות מוגדרות כברירת מחדל למקשי קיצור ספציפיים ל-VS Code. המשימות הופכות לפשוטות יותר: "לוחצים על הלחצן הזה" או "מקישים על המקש הזה כדי לבצע את הפעולה X" במקום "מבצעים את הפעולה המתאימה בכלי העריכה כדי לבצע את הפעולה X".
אתם יכולים להשתמש בכל עורך שתרצו: Android Studio, סביבות פיתוח משולבות (IDE) אחרות של IntelliJ, Emacs, Vim או Notepad++. כולם פועלים עם Flutter.
בחירת יעד פיתוח
אפשר ליצור אפליקציות ל-Flutter לפלטפורמות שונות. האפליקציה יכולה לפעול בכל אחת ממערכות ההפעלה הבאות:
- iOS
- Android
- Windows
- macOS
- Linux
- אינטרנט
מקובל לבחור מערכת הפעלה אחת כיעד הפיתוח. זוהי מערכת ההפעלה שבה האפליקציה פועלת במהלך הפיתוח.
לדוגמה: נניח שאתם משתמשים במחשב נייד עם Windows כדי לפתח את אפליקציית Flutter. לאחר מכן, בוחרים ב-Android בתור יעד הפיתוח. כדי להציג תצוגה מקדימה של האפליקציה, מחברים מכשיר Android למחשב הנייד עם Windows באמצעות כבל USB, והאפליקציה שנמצאת בפיתוח פועלת במכשיר Android המחובר או במהדמ של Android. אפשר היה לבחור ב-Windows כיעד הפיתוח, שבו האפליקציה שנמצאת בפיתוח תפעל כאפליקציית Windows לצד הכלי לעריכה.
יכול להיות שתתפתתו לבחור באינטרנט כיעד הפיתוח. לכך יש חיסרון במהלך הפיתוח: אתם מפסידים את היכולת של Flutter לטעינה מחדש בזמן אמת (Hot Reload) עם שמירת המצב. בשלב הזה אי אפשר לטעון מחדש אפליקציות אינטרנט ב-Flutter.
בוחרים אפשרות לפני שממשיכים. תמיד תוכלו להריץ את האפליקציה במערכות הפעלה אחרות מאוחר יותר. בחירת יעד פיתוח תעזור לכם לבצע את השלב הבא בצורה חלקה יותר.
התקנת Flutter
ההוראות העדכניות ביותר להתקנת Flutter SDK מפורטות בכתובת docs.flutter.dev.
ההוראות באתר של Flutter כוללות התקנה של ה-SDK, של הכלים שקשורים ליעד הפיתוח ושל הפלאגינים של העריכה. כדי להשתמש ב-codelab הזה, צריך להתקין את התוכנות הבאות:
- Flutter SDK
- Visual Studio Code עם הפלאגין של Flutter
- תוכנת מהדר ליעד הפיתוח שבחרתם. (צריך את Visual Studio כדי לטרגט ל-Windows או את Xcode כדי לטרגט ל-macOS או ל-iOS)
בקטע הבא תלמדו ליצור את פרויקט Flutter הראשון שלכם.
אם אתם צריכים לפתור בעיות, יכול להיות שחלק מהשאלות והתשובות האלה (מ-StackOverflow) יעזרו לכם.
שאלות נפוצות
- איך מוצאים את הנתיב של Flutter SDK?
- מה עושים אם לא מוצאים את הפקודה Flutter?
- איך פותרים את הבעיה 'Waiting for another flutter command to release the startup lock'?
- איך מציינים ל-Flutter איפה מותקן Android SDK?
- איך מטפלים בשגיאת Java כשמריצים את
flutter doctor --android-licenses
? - מה עושים אם הכלי ל-Android
sdkmanager
לא נמצא? - איך מטפלים בשגיאה 'רכיב
cmdline-tools
חסר'? - איך מפעילים את CocoaPods ב-Apple Silicon (M1)?
- איך משביתים את העיצוב האוטומטי בזמן שמירת קובץ ב-VS Code?
3. יצירת פרויקט
יצירת הפרויקט הראשון ב-Flutter
לשם כך, פותחים את VS Code ויוצרים את תבנית האפליקציה של Flutter בספרייה שבוחרים.
- מריצים את Visual Studio Code.
- פותחים את לוח הפקודות (
F1
אוCtrl+Shift+P
אוShift+Cmd+P
) ומקלידים 'flutter new'. כשהוא מופיע, בוחרים בפקודה Flutter: New Project.
- בוחרים באפשרות Empty Application. בוחרים ספרייה שבה ייווצר הפרויקט. זו יכולה להיות כל ספרייה שלא דורשת הרשאות מוגברות או שיש בה רווח בנתיב. דוגמאות לכך הן ספריית הבית או
C:\src\
.
- נותנים לפרויקט שם –
brick_breaker
. בהמשך הקודלהב הזה נניח שהשם של האפליקציה הואbrick_breaker
.
עכשיו Flutter יוצר את תיקיית הפרויקט ו-VS Code פותח אותה. עכשיו נמחק את התוכן של שני קבצים ונחליף אותו בתוכנית בסיסית של האפליקציה.
העתקה והדבקה של האפליקציה הראשונית
הפעולה הזו מוסיפה לאפליקציה את קוד הדוגמה שסופק בקודלאב הזה.
- בחלונית הימנית של VS Code, לוחצים על Explorer ופותחים את הקובץ
pubspec.yaml
.
- מחליפים את התוכן של הקובץ הזה בקוד הבא:
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
מציין מידע בסיסי על האפליקציה, כמו הגרסה הנוכחית שלה, יחסי התלות שלה והנכסים שהיא תישלח איתם.
- פותחים את הקובץ
main.dart
בספרייהlib/
.
- מחליפים את התוכן של הקובץ הזה בקוד הבא:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- מריצים את הקוד הזה כדי לוודא שהכול פועל. אמור להופיע חלון חדש עם רקע שחור ריק בלבד. משחק הווידאו הכי גרוע בעולם מעובד עכשיו ב-60fps!
4. יצירת המשחק
סקירה כללית של המשחק
במשחקים שמתנהלים בשני ממדים (2D) צריך להגדיר אזור משחק. תיצרו אזור בגודל ספציפי, ואז תשתמשו בגודל הזה כדי לקבוע את הגודל של היבטים אחרים במשחק.
יש כמה דרכים למקם את הקואורדינטות באזור המשחק. לפי הסכמה אחת, אפשר למדוד את הכיוון ממרכז המסך, כאשר המקור (0,0)
נמצא במרכז המסך. ערכים חיוביים מעבירים פריטים ימינה לאורך ציר x ומעלה לאורך ציר y. התקן הזה רלוונטי לרוב המשחקים הקיימים כיום, במיוחד למשחקים שכוללים שלושה מימדים.
כשהמשחק המקורי Breakout נוצר, ההסכמה הייתה להגדיר את המקור בפינה הימנית העליונה. כיוון ה-x החיובי נשאר ללא שינוי, אבל ה-y התהפך. הכיוון החיובי של x היה ימינה ו-y היה למטה. כדי לשמור על אותנטיות התקופה, המקור במשחק הזה מוגדר בפינה הימנית העליונה.
יוצרים קובץ בשם config.dart
בספרייה חדשה בשם lib/src
. בשלבים הבאים יתווספו לקובץ הזה עוד קבועים.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
המשחק הזה יהיה ברוחב 820 פיקסלים ובגובה 1,600 פיקסלים. אזור המשחק משתנה בהתאם לחלון שבו הוא מוצג, אבל כל הרכיבים שנוספו למסך מותאמים לגובה ולרוחב הזה.
יצירת PlayArea
במשחק Breakout, הכדור ניתז מהקירות של אזור המשחק. כדי לטפל בהתנגשויות, קודם צריך רכיב PlayArea
.
- יוצרים קובץ בשם
play_area.dart
בספרייה חדשה בשםlib/src/components
. - מוסיפים את הטקסט הבא לקובץ הזה.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
ב-Flutter יש Widget
, וב-Flame יש Component
. אפליקציות Flutter מורכבות מעצים של ווידג'טים, ומשחקים ב-Flame מורכבים מעצים של רכיבים.
זהו הבדל מעניין בין Flutter לבין Flame. עץ הווידג'טים של Flutter הוא תיאור זמני שנועד לעדכן את השכבה RenderObject
הקבועה והניתנת לשינוי. הרכיבים של Flame הם עמידים וניתנים לשינוי, והמפתחים אמורים להשתמש בהם כחלק ממערכת סימולציה.
הרכיבים של Flame מותאמים במיוחד להצגת המכניקה של המשחק. סדנת הקוד הזו תתחיל בלולאת המשחק, שתוצג בשלב הבא.
- כדי למנוע עומס, מוסיפים קובץ שמכיל את כל הרכיבים בפרויקט הזה. יוצרים קובץ
components.dart
ב-lib/src/components
ומוסיפים את התוכן הבא.
lib/src/components/components.dart
export 'play_area.dart';
ההנחיה export
ממלאת את התפקיד ההפוך של import
. הוא מצהיר על הפונקציונליות שהקובץ הזה חושף כשמייבאים אותו לקובץ אחר. ככל שתוסיפו רכיבים חדשים בשלבים הבאים, יתווספו עוד רשומות לקובץ הזה.
יצירת משחק ב-Flame
כדי להפסיק את ההצגה של הקווים האדומים מהשלב הקודם, יוצרים צאצא חדש של FlameGame
של Flame.
- יוצרים קובץ בשם
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
שהוחלפה, הקוד מבצע שתי פעולות.
- מגדיר את הפינה השמאלית העליונה כציון העוגן של העינית. כברירת מחדל, ה-
viewfinder
משתמש במרכז האזור כעוגן ל-(0,0)
. - הוספת
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));
}
אחרי ביצוע השינויים האלה, מפעילים מחדש את המשחק. המשחק אמור להיראות כמו בתרשים הבא.
בשלב הבא תוסיפו כדור לעולם ותגרמו לו לזוז.
5. הצגת הכדור
יצירת הרכיב של הכדור
כדי להציג כדור נע במסך, צריך ליצור רכיב נוסף ולהוסיף אותו לעולם המשחק.
- עורכים את התוכן של הקובץ
lib/src/config.dart
באופן הבא.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
תבנית העיצוב של הגדרת קבועים עם שם כערכים נגזרים תופיע פעמים רבות בקודלאב הזה. כך תוכלו לשנות את הערכים ברמה העליונה gameWidth
ו-gameHeight
כדי לראות איך המראה והתחושה של המשחק משתנים כתוצאה מכך.
- יוצרים את הרכיב
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
. כך מטמיעים עדכון של סימולציה דיסקרטית של תנועה לאורך זמן.
- כדי לכלול את הרכיב
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 מגובה המשחק.
כדי להגיע לערכים הנכונים, צריך לבצע כמה חזרות, שנקראות בתעשייה 'בדיקת גיימינג'.
השורה האחרונה מפעילה את תצוגת ניפוי הבאגים, שמוסיפה מידע נוסף לתצוגה כדי לעזור בניפוי הבאגים.
כשמפעילים את המשחק, הוא אמור להיראות כמו התצוגה הבאה.
גם לרכיב PlayArea
וגם לרכיב Ball
יש מידע על ניפוי באגים, אבל שכבות המט של הרקע חתוכות כך שהמספרים של PlayArea
לא מוצגים. הסיבה לכך שמוצגים נתוני ניפוי באגים בכל מקום היא שהפעלתם את debugMode
לכל עץ הרכיבים. אם זה שימושי יותר, אפשר גם להפעיל את ניפוי הבאגים רק לרכיבים נבחרים.
אם תפעילו מחדש את המשחק כמה פעמים, יכול להיות שתבחינו שהכדור לא מתנהג עם הקירות כמצופה. כדי להשיג את האפקט הזה, צריך להוסיף זיהוי התנגשויות. בשלב הבא נסביר איך עושים את זה.
6. קפיצה ממקום למקום
הוספת זיהוי התנגשויות
זיהוי התנגשויות מוסיף התנהגות שבה המשחק מזהה מתי שני אובייקטים נכנסים במגע זה עם זה.
כדי להוסיף למשחק זיהוי התנגשויות, מוסיפים את ה-mixin HasCollisionDetection
למשחק BrickBreaker
כפי שמוצג בקוד הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true;
}
}
כך אפשר לעקוב אחרי אזורי ההיט של הרכיבים ולהפעיל קריאות חזרה (callbacks) של התנגשויות בכל טייק של המשחק.
כדי להתחיל לאכלס את ה-hitboxes של המשחק, משנים את הרכיב PlayArea
כפי שמתואר בהמשך.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
הוספת רכיב RectangleHitbox
כצאצא של RectangleComponent
תיצור תיבת פגיעה לזיהוי התנגשויות שתתאים לגודל של רכיב ההורה. יש ב-RectangleHitbox
קונסטרוקטור של מפעל שנקרא relative
, לפעמים שבהן רוצים hitbox קטן או גדול יותר מהרכיב ההורה.
להקפיץ את הכדור
עד עכשיו, הוספת זיהוי התנגשויות לא השפיעה על משחקיות. הוא משתנה אחרי שמבצעים שינוי ברכיב Ball
. התנהגות הכדור צריכה להשתנות כשהוא מתנגש ב-PlayArea
.
משנים את הרכיב Ball
באופן הבא.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
בדוגמה הזו יש שינוי משמעותי עם הוספת קריאה חוזרת (callback) של onCollisionStart
. מערכת זיהוי ההתנגשויות שנוספה ל-BrickBreaker
בדוגמה הקודמת קוראת ל-callback הזה.
קודם הקוד בודק אם Ball
התנגש ב-PlayArea
. נראה שזה מיותר כרגע, כי אין רכיבים אחרים בעולם המשחק. המצב ישתנה בשלב הבא, כשמוסיפים עטלף לעולם. לאחר מכן, המערכת מוסיפה גם תנאי else
לטיפול במקרים שבהם הכדור מתנגש בדברים שאינם המחבט. תזכורת חביבה להטמיע את שאר הלוגיקה, אם תרצו.
כשהכדור פוגש בקיר התחתון, הוא פשוט נעלם משטח המשחק, אבל עדיין נראה בבירור. לטפל בבעיה הזו בשלב עתידי, באמצעות היכולות של Flame Effects.
עכשיו, כשהכדור פוגש בקירות המשחק, כדאי לתת לשחקן מחבט כדי להכות בכדור…
7. להכות בכדור
יצירת ה-bat
כדי להוסיף מקל בייסבול כדי שהכדור יישאר במשחק,
- מוסיפים כמה קבועים לקובץ
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
קובע את המרחק שאליו יתקדם העטלף בכל לחיצה על מקש החץ שמאלה או ימינה.
- מגדירים את סוג הרכיב
Bat
באופן הבא.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
_paint,
);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(
MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
),
);
}
}
הרכיב הזה כולל כמה יכולות חדשות.
ראשית, רכיב ה-Bat הוא PositionComponent
, ולא RectangleComponent
או CircleComponent
. המשמעות היא שהקוד הזה צריך להציג את Bat
במסך. כדי לעשות זאת, הוא מבטל את ההפעלה החוזרת (callback) של render
.
אם תבחנו לעומק את הקריאה canvas.drawRRect
(ציור מלבן מעוגל), יכול להיות שתתהו "איפה המלבן?" הפונקציה Offset.zero & size.toSize()
משתמשת בעומס יתר של operator &
בכיתה Offset
של dart:ui
, שיוצרת Rect
. יכול להיות שהקיצור הזה יבלבל אתכם בהתחלה, אבל תראו אותו לעיתים קרובות בקוד ברמה נמוכה יותר של Flutter ו-Flame.
שנית, אפשר לגרור את הרכיב Bat
באמצעות אצבע או עכבר, בהתאם לפלטפורמה. כדי להטמיע את הפונקציונליות הזו, מוסיפים את ה-mixin DragCallbacks
ומחליפים את האירוע onDragUpdate
.
לבסוף, הרכיב Bat
צריך להגיב לשליטה במקלדת. הפונקציה moveBy
מאפשרת לקוד אחר להורות לעטלף הזה לזוז שמאלה או ימינה במספר מסוים של פיקסלים וירטואליים. הפונקציה הזו מציגה יכולת חדשה של מנוע המשחק Flame: Effect
s. הוספת האובייקט MoveToEffect
כצאצא של הרכיב הזה מאפשרת לשחקן לראות את העטלף נע למוצב חדש. יש ב-Flame אוסף של Effect
s שאפשר להשתמש בהם כדי ליצור מגוון אפקטים.
הארגומנטים של ה-constructor של Effect כוללים הפניה ל-getter של game
. לכן צריך לכלול את ה-mixin HasGameReference
בכיתה הזו. ה-mixin הזה מוסיף לרכיב הזה רכיב גישה (accessor) מסוג game
ללא סיכון בטיפוסים, כדי לגשת למכונה BrickBreaker
בחלק העליון של עץ הרכיבים.
- כדי שה-
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. הורדת החומה
יצירת הלבנים
כדי להוסיף לבנים למשחק:
- מוסיפים כמה קבועים לקובץ
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.
- מוסיפים את הרכיב
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. לנצח במשחק
הוספת מצבי משחק
בשלב הזה, מטמיעים את המשחק Flame בתוך מעטפת של Flutter, ואז מוסיפים שכבות-על של Flutter למסכים של קבלת הפנים, הסיום והזכייה.
קודם כול, משנים את קובצי המשחק והרכיבים כדי להטמיע מצב משחק שמשקף אם להציג שכבת-על, ואם כן, איזו שכבת-על.
- משנים את המשחק
BrickBreaker
באופן הבא.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
הקוד הזה משנה חלק גדול מהמשחק BrickBreaker
. הוספת המניין playState
דורשת הרבה עבודה. הנתונים האלה מתעדים את השלבים שבהם השחקן נכנס למשחק, משחק בו ומפסיד או מנצח בו. בחלק העליון של הקובץ מגדירים את המניין, ואז יוצרים מופע שלו כמצב מוסתר עם פונקציות getter ו-setter תואמות. פונקציות ה-getter וה-setter האלה מאפשרות לשנות שכבות-על כשחלקים שונים במשחק מפעילים מעברים במצב המשחק.
בשלב הבא, מחלקים את הקוד ב-onLoad
ל-onLoad ולשיטה חדשה של startGame
. לפני השינוי הזה, אפשר היה להתחיל משחק חדש רק על ידי הפעלה מחדש של המשחק. בעזרת התוספות החדשות האלה, השחקן יכול עכשיו להתחיל משחק חדש בלי לנקוט אמצעים דרסטיים כאלה.
כדי לאפשר לשחקן להתחיל משחק חדש, הגדרתם שני מנהלים חדשים למשחק. הוספתם טיפול בהקשה והרחבתם את הטיפול במקלדת כדי לאפשר למשתמש להתחיל משחק חדש במספר מודלים. אחרי שיוצרים מודל של מצב המשחק, כדאי לעדכן את הרכיבים כדי להפעיל את מעברי מצב המשחק כשהשחקן מנצח או מפסיד.
- משנים את הרכיב
Ball
באופן הבא.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints,
PositionComponent other,
) {
super.onCollisionStart(intersectionPoints, other);
if (other is PlayArea) {
if (intersectionPoints.first.y <= 0) {
velocity.y = -velocity.y;
} else if (intersectionPoints.first.x <= 0) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.x >= game.width) {
velocity.x = -velocity.x;
} else if (intersectionPoints.first.y >= game.height) {
add(
RemoveEffect(
delay: 0.35,
onComplete: () { // Modify from here
game.playState = PlayState.gameOver;
},
),
); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
if (position.y < other.position.y - other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.y > other.position.y + other.size.y / 2) {
velocity.y = -velocity.y;
} else if (position.x < other.position.x) {
velocity.x = -velocity.x;
} else if (position.x > other.position.x) {
velocity.x = -velocity.x;
}
velocity.setFrom(velocity * difficultyModifier);
}
}
}
השינוי הקטן הזה מוסיף קריאה חוזרת (callback) מסוג onComplete
ל-RemoveEffect
, שמפעילה את מצב ההפעלה gameOver
. המיקום הזה אמור להרגיש נכון אם השחקן מאפשר לכדור לצאת מתחתית המסך.
- עורכים את הרכיב
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.
- יוצרים ספרייה
widgets
ב-lib/src
. - מוסיפים קובץ
game_app.dart
ומוסיפים את התוכן הבא לקובץ הזה.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
const GameApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style: Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
רוב התוכן בקובץ הזה עוקב אחרי build סטנדרטי של עץ ווידג'טים ב-Flutter. החלקים הספציפיים ל-Flame כוללים שימוש ב-GameWidget.controlled
כדי ליצור ולנהל את מכונה של המשחק BrickBreaker
ואת הארגומנט החדש overlayBuilderMap
ל-GameWidget
.
המפתחות של overlayBuilderMap
צריכים להיות תואמים לשכבות-העל שהוספו או הוסרו על ידי הגדרת playState
ב-BrickBreaker
. ניסיון להגדיר שכבת-על שלא נמצאת במפה הזו יוביל לפרצופים לא מרוצים מכל עבר.
- כדי להציג את הפונקציונליות החדשה הזו במסך, צריך להחליף את הקובץ
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, צריך לערוך שני קבצים.
- עורכים את הקובץ
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>
- עורכים את הקובץ
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. כך קוד המשחק יכול לעדכן את הציון בכל פעם שהשחקן שובר לבנה.
- משנים את המשחק
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.
- משנים את הכיתה
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, הגיע הזמן להרכיב את הווידג'טים כדי שהם ייראו טוב.
- יוצרים את
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!,
),
);
},
);
}
}
- יוצרים את הקובץ
overlay_screen.dart
ב-lib/src/widgets
ומוסיפים את הקוד הבא.
כך אפשר לשפר את שכבות-העל באמצעות חבילת flutter_animate
כדי להוסיף תנועה וסגנון למסכי שכבת-העל.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({super.key, required this.title, required this.subtitle});
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Container(
alignment: const Alignment(0, -0.15),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: Theme.of(context).textTheme.headlineLarge,
).animate().slideY(duration: 750.ms, begin: -3, end: 0),
const SizedBox(height: 16),
Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
.animate(onPlay: (controller) => controller.repeat())
.fadeIn(duration: 1.seconds)
.then()
.fadeOut(duration: 1.seconds),
],
),
);
}
}
כדי לקבל מידע מעמיק יותר על היכולות של flutter_animate
, מומלץ לעיין בקודלאב יצירת ממשקי משתמש מדור הבא ב-Flutter.
הקוד הזה השתנה מאוד ברכיב GameApp
. קודם כול, כדי לאפשר ל-ScoreCard
לגשת ל-score
, צריך להמיר אותו מ-StatelessWidget
ל-StatefulWidget
. כדי להוסיף את כרטיס המידע, צריך להוסיף Column
כדי להציב את התוצאה מעל המשחק.
שנית, כדי לשפר את חוויית הפתיחה, הסיום והזכייה, הוספתם את הווידג'ט החדש OverlayScreen
.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
עכשיו אמורה להיות לכם אפשרות להריץ את המשחק בכל אחת משש פלטפורמות היעד של Flutter. המשחק אמור להיראות כך.
11. מזל טוב
ברכות, הצלחתם ליצור משחק באמצעות Flutter ו-Flame!
פיתחתם משחק באמצעות מנוע המשחקים Flame 2D והטמעתם אותו ב-wrapper של Flutter. השתמשתם באפקטים של Flame כדי להוסיף אנימציה לרכיבים ולהסיר אותם. השתמשתם ב-Google Fonts ובחבילות Flutter Animate כדי שהמשחק כולו ייראה מעוצב היטב.
מה השלב הבא?
כדאי לעיין בחלק מהקורסים האלה ב-Codelab…
- פיתוח ממשקי משתמש מהדור הבא ב-Flutter
- איך הופכים אפליקציה ב-Flutter משעממת ליפה
- הוספת רכישות מתוך האפליקציה לאפליקציה ב-Flutter