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.
בחירת יעד פיתוח
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 הזה, מתקינים את התוכנה הבאה:
- SDK של Flutter
- Visual Studio Code עם הפלאגין Flutter
- תוכנת הידור עבור יעד הפיתוח שבחרתם. (צריך להשתמש ב-Visual Studio כדי לטרגט ל-Windows או ל-Xcode כדי לטרגט macOS או iOS)
בקטע הבא תיצרו את פרויקט Flutter הראשון שלכם.
אם אתם צריכים לפתור בעיות, יכול להיות שחלק מהשאלות והתשובות האלה (מ-StackOverflow) יעזרו לכם לפתור את הבעיה.
שאלות נפוצות
- איך מוצאים את הנתיב של Flutter SDK?
- מה עושים אם הפקודה Flutter לא נמצאה?
- איך מתקנים את השגיאה "בהמתנה לפקודת Flutter נוספת כדי לשחרר את נעילת ההפעלה" בעיה?
- איך אפשר לדעת ל-Flutter איפה נמצאת התקנת ה-SDK של Android?
- איך מתמודדים עם שגיאת Java כשמריצים את
flutter doctor --android-licenses
? - איך מתמודדים עם הכלי
sdkmanager
של Android לא נמצא? - איך פותרים את הבעיה 'הרכיב
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
. המשך התהליך הזה ב-Codelab מבוסס על ההנחה שנתת לאפליקציה את השםbrick_breaker
.
עכשיו Flutter יוצרת את תיקיית הפרויקט שלך ו-VS Code פותח אותה. עכשיו התוכן של שני קבצים יוחלף בפיגום בסיסי של האפליקציה.
העתקה & הדבקת האפליקציה הראשונית
הפעולה הזו מוסיפה לאפליקציה שלך את הקוד לדוגמה שסופק ב-Codelab הזה.
- בחלונית השמאלית של 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.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
מציין מידע בסיסי על האפליקציה, כמו הגרסה הנוכחית, יחסי התלות שלה והנכסים שאיתם היא תישלח.
- פתיחת הקובץ
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. יצירת המשחק
משדרגים את המשחק
כשמשחקים במשחק דו-ממדי (דו-ממד), צריך לבחור אזור משחקים. עליכם ליצור אזור עם מאפיינים ספציפיים, ולאחר מכן להשתמש במאפיינים האלה כדי להתאים היבטים אחרים במשחק.
יש כמה דרכים לפרוס את הקואורדינטות באזור המשחק. לפי מוסכמה אחת, אפשר למדוד את הכיוון ממרכז המסך עם המקור (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
.
- יוצרים קובץ בשם
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
, ול-Fflame יש Component
. אפליקציות Flutter כוללות יצירת עצים של ווידג'טים, משחקי להבות מורכבים משמירת עצים של רכיבים.
כאן טמון הבדל מעניין בין Flutter ו-Fflame. עץ הווידג'ט של Flutter הוא תיאור זמני שנועד לשמש לעדכון שכבת RenderObject
הקבועה וניתנת לשינוי. הרכיבים של Flame הם קבועים וניתנים לשינוי, ואפשר לצפות שהמפתח ישתמש ברכיבים האלה כחלק ממערכת סימולציה.
רכיבי Flame מותאמים לביטוי של מנגנון המשחק. ה-Codelab הזה יתחיל עם לולאת המשחק, שיוצג בשלב הבא.
- כדי לשלוט בעומס, מוסיפים קובץ שמכיל את כל הרכיבים בפרויקט הזה. יצירת קובץ
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());
}
}
הקובץ הזה מתאם את פעולות המשחק. במהלך בניית מופע המשחק, הקוד הזה מגדיר את המשחק לשימוש ברינדור ברזולוציה קבועה. גודל המשחק משתנה כך שימלא את המסך שבו הוא נמצא, ויתווסף פורמט letterbox לפי הצורך.
צריך לחשוף את הרוחב והגובה של המשחק כדי שרכיבי הצאצא, כמו PlayArea
, יוכלו להגדיר את עצמם לגודל המתאים.
בשיטה onLoad
מבוטלת, הקוד מבצע שתי פעולות.
- מגדירה את הפינה השמאלית העליונה כעוגן של העינית. כברירת מחדל, העינית משתמשת באמצע האזור כעוגן של
(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
דפוס העיצוב של הגדרת קבועים בעלי שם כערכים נגזרים יוחזר פעמים רבות ב-Codelab הזה. כך אפשר לשנות את הרמה העליונה של 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 מגובה המשחק.
כדי להשיג את הערכים השונים האלה בצורה נכונה, צריך לבצע איטרציה מסוימת, שידועה גם כ-playtest בתחום.
השורה האחרונה מפעילה את תצוגת ניפוי הבאגים. תצוגה זו מוסיפה מידע לתצוגה שעוזר בניפוי באגים.
עכשיו, כשאתם מפעילים את המשחק, הוא אמור להיראות כך:
גם לרכיב 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. חובטים במחבט על הכדור
יצירת מחבט
כדי להוסיף מחבט כדי שהכדור ימשיך לשחק במשחק:
- מוסיפים קבועים בקובץ
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
במסך. כדי לעשות זאת, הוא מבטל את הקריאה החוזרת של 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
בחלק העליון של עץ הרכיבים.
- כדי להפוך את
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. שבר את הקיר
יוצרים את הלבנים
כדי להוסיף לבנים למשחק:
- מוסיפים קבועים בקובץ
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. מנצחים במשחק
הוספת מצבי Play
בשלב הזה, תטמיעו את משחק Flame בתוך wrapper של 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
לוקחת הרבה עבודה. המדד הזה מציין איפה השחקן נכנס, משחק או מפסיד או מנצח במשחק. בחלק העליון של הקובץ אתם מגדירים את המספור ואז יוצרים אותה כמצב מוסתר עם מערכי נתונים וקובעים תואמים. הקובעים והקובעים האלה מאפשרים שינוי שכבות-על כאשר החלקים השונים של המשחק עוברים למצב של הפעלת המשחק.
בשלב הבא, מפצלים את הקוד ב-onLoad
ל-onLoad ולשיטת startGame
חדשה. לפני השינוי הזה, הייתם יכולים להתחיל משחק חדש רק על ידי הפעלה מחדש של המשחק. עכשיו, בעזרת התוספות החדשות האלה, השחקן יכול להתחיל משחק חדש בלי פעולות קיצוניות כאלה.
כדי לאשר לשחקן להתחיל משחק חדש, הגדרת שני רכיבי handler חדשים למשחק. הוספתם handler הקשה והרחבתם את ה-handler של המקלדת כדי לאפשר למשתמש להתחיל משחק חדש בכמה שיטות. בגלל מודל של מצב ההפעלה, הגיוני לעדכן את הרכיבים כדי להפעיל מעברים בין מצבי משחק כשהשחקן מנצח או מפסיד.
- צריך לשנות את הרכיב
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
. ההרגשה הזו אמורה להיות נכונה אם השחקן מאפשר לכדור להימלט מתחתית המסך.
- אפשר לערוך את הרכיב
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.
- יוצרים ספרייה בשם
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(
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
הוסיף או הסיר. ניסיון להגדיר שכבת-על שאינה במפה הזו תוביל לפנים לא מרוצים בכל רחבי המקום.
- כדי שהפונקציונליות החדשה הזו תוצג במסך, צריך להחליף את הקובץ
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
, כדאי לעיין במאמר יצירת ממשקי משתמש מהדור הבא ב-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. המשחק אמור להיראות כך.
11. מזל טוב
כל הכבוד, הצלחת לבנות משחק עם Flutter ו-Fflame!
יצרת משחק באמצעות מנוע המשחק Flame 2D והטמעת אותו ב-Flutter wrapper. השתמשת באפקטים של Flame כדי להוסיף אנימציה לרכיבים ולהסיר אותם. השתמשת בחבילות Google Fonts ו-Flutter Animate כדי לעצב את המשחק כולו בצורה טובה.
מה השלב הבא?
כדאי לנסות כמה מ-Codelabs האלה...
- יצירת ממשקי משתמש של הדור הבא ב-Flutter
- הופכים את אפליקציית Flutter משעמם ליפה
- הוספת רכישות מתוך האפליקציה לאפליקציית Flutter